This commit is contained in:
2026-05-27 23:15:17 +03:00
commit b9357582e4
285 changed files with 29020 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain
+385
View File
@@ -0,0 +1,385 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+30
View File
@@ -0,0 +1,30 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
!**/.gitignore
!.git/HEAD
!.git/config
!.git/packed-refs
!.git/refs/heads/**
+4
View File
@@ -0,0 +1,4 @@
[*.cs]
# CS1591: Отсутствует комментарий XML для открытого видимого типа или члена
dotnet_diagnostic.CS1591.severity = none
+33
View File
@@ -0,0 +1,33 @@
# См. статью по ссылке https://aka.ms/customizecontainer, чтобы узнать как настроить контейнер отладки и как Visual Studio использует этот Dockerfile для создания образов для ускорения отладки.
# Этот этап используется при запуске из VS в быстром режиме (по умолчанию для конфигурации отладки)
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
# Этот этап используется для сборки проекта службы
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Lockena.API/Lockena.API.csproj", "Lockena.API/"]
COPY ["Lockena.Application/Lockena.Application.csproj", "Lockena.Application/"]
COPY ["Lockena.Domain/Lockena.Domain.csproj", "Lockena.Domain/"]
COPY ["Lockena.Infrastructure/Lockena.Infrastructure.csproj", "Lockena.Infrastructure/"]
RUN dotnet restore "./Lockena.API/Lockena.API.csproj"
COPY . .
WORKDIR "/src/Lockena.API"
RUN dotnet build "./Lockena.API.csproj" -c $BUILD_CONFIGURATION -o /app/build
# Этот этап используется для публикации проекта службы, который будет скопирован на последний этап
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Lockena.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# Этот этап используется в рабочей среде или при запуске из VS в обычном режиме (по умолчанию, когда конфигурация отладки не используется)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Lockena.API.dll"]
+137
View File
@@ -0,0 +1,137 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Lockena.API.Middleware;
using Lockena.Application.Common;
using Lockena.Application.DTO;
using Lockena.Application.DTO.Auth;
using Lockena.Application.Interfaces.Services;
namespace Lockena.API.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class AuthController(IAuthService authService, IOptions<JwtSettings> jwtSettings)
: ControllerBase
{
private readonly IAuthService _authService = authService;
private readonly JwtSettings _jwtSettings = jwtSettings.Value;
[HttpPost("signUp")]
public async Task<ActionResult<AuthDto>> SignUp([FromBody] SignUpDto request)
{
var result = await _authService.SignUpAsync(request, GetFingerprintString());
if (!result.IsSuccess || result.Value == null)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
AppendCookie(result.Value);
return CreatedAtAction(nameof(SignUp), result.Value);
}
[HttpPost("signIn")]
public async Task<ActionResult<AuthDto>> SignIn([FromBody] SignInDto request)
{
var result = await _authService.SignInAsync(request, GetFingerprintString());
if (!result.IsSuccess || result.Value == null)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
AppendCookie(result.Value);
return Ok(result.Value);
}
[HttpPost("refresh")]
public async Task<ActionResult<AuthDto>> Refresh()
{
Request.Cookies.TryGetValue("refreshToken", out var refreshToken);
if (string.IsNullOrEmpty(refreshToken))
return Unauthorized();
var result = await _authService.RefreshAsync(refreshToken, GetFingerprintString());
if (!result.IsSuccess || result.Value == null)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
AppendCookie(result.Value);
return Ok(result.Value);
}
[Authorize]
[HttpPost("logout")]
public async Task<ActionResult<MessageDto>> Logout()
{
var userId = HttpContext.GetUserId();
var result = await _authService.LogoutAsync(userId, GetFingerprintString());
if (!result.IsSuccess)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
Response.Cookies.Delete("refreshToken");
return Ok(new MessageDto(result.Value ?? "Success", 200));
}
[HttpPost("telegram-signup")]
public async Task<ActionResult<AuthDto>> TelegramSignUp([FromBody] TelegramSignUpDto request)
{
var result = await _authService.TelegramSignUpAsync(request, GetFingerprintString());
if (!result.IsSuccess || result.Value == null)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
AppendCookie(result.Value);
return Ok(result.Value);
}
[HttpPost("telegram-signin")]
public async Task<ActionResult<AuthDto>> TelegramSignIn([FromBody] TelegramSignInDto request)
{
var result = await _authService.TelegramSignInAsync(request, GetFingerprintString());
if (!result.IsSuccess || result.Value == null)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
AppendCookie(result.Value);
return Ok(result.Value);
}
[Authorize]
[HttpPost("telegram-link-email")]
public async Task<ActionResult<MessageDto>> TelegramLinkEmail([FromBody] LinkEmailDto request)
{
var userId = HttpContext.GetUserId();
var result = await _authService.TelegramLinkEmail(userId, request);
if (!result.IsSuccess || result.Value == null)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
return Ok(new MessageDto(result.Value ?? "Success", 200));
}
[HttpPost("confirm-email")]
public async Task<ActionResult<MessageDto>> ConfirmEmail([FromBody] ConfirmEmailDto request)
{
var result = await _authService.ConfirmEmail(request);
if (!result.IsSuccess || result.Value == null)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
return Ok(new MessageDto(result.Value ?? "Success", 200));
}
[HttpPost("send-email-confirmation")]
public async Task<ActionResult<MessageDto>> SendEmailConfirmation([FromBody] SendEmailConfirmation request)
{
var result = await _authService.SendEmailConfirmation(request);
if (!result.IsSuccess || result.Value == null)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
return Ok(new MessageDto(result.Value ?? "Success", 200));
}
private void AppendCookie(AuthDto auth)
{
var options = new CookieOptions()
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.None,
MaxAge = TimeSpan.FromDays(_jwtSettings.RefreshTokenLifetimeDays)
};
Response.Cookies.Append("refreshToken", auth.RefreshToken, options);
}
private string GetFingerprintString()
{
IHeaderDictionary headers = Request.Headers;
if (headers.Origin.ToString().StartsWith("chrome-extension"))
return "extension";
return $"{headers.UserAgent};{headers.AcceptLanguage};{headers.AcceptEncoding};";
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Lockena.Application.Interfaces.Services;
namespace Lockena.API.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class LogoController : ControllerBase
{
private readonly ILogoService _logoService;
private readonly IMemoryCache _cache;
public LogoController(ILogoService logoService, IMemoryCache cache)
{
_logoService = logoService;
_cache = cache;
}
[Authorize]
[HttpGet("{service}")]
[ResponseCache(Duration = 3600 * 24, Location = ResponseCacheLocation.Client, NoStore = false)]
public async Task<ActionResult> GetLogo(string service)
{
var cacheKey = $"logo_{service}";
if(_cache.TryGetValue(cacheKey, out byte[] cachedImage))
return File(cachedImage, "image/png");
var logo = await _logoService.GetLogoAsync(service);
if (!logo.IsSuccess || logo.Value == null)
return NotFound();
var cacheOptions = new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(30),
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24),
Size = logo.Value.Length
};
_cache.Set(cacheKey, logo.Value, cacheOptions);
return File(logo.Value, "image/png");
}
}
}
+50
View File
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Lockena.API.Middleware;
using Lockena.Application.DTO;
using Lockena.Application.DTO.User;
using Lockena.Application.Interfaces.Services;
namespace Lockena.API.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpGet]
[Authorize]
public async Task<ActionResult<ProfileDto>> GetProfile()
{
var userId = HttpContext.GetUserId();
var result = await _userService.GetUserAsync(userId);
if (!result.IsSuccess) return BadRequest(new ErrorDto(result.Errors ?? [], 400));
return Ok(result.Value);
}
[HttpPut("change-password")]
[Authorize]
public async Task<ActionResult<MessageDto>> ChangePassword([FromBody] ChangePasswordDto request)
{
var userId = HttpContext.GetUserId();
var result = await _userService.ChangePasswordAsync(userId, request);
if (!result.IsSuccess) return BadRequest(new ErrorDto(result.Errors ?? [], 400));
return Ok(new MessageDto(result.Value ?? "", 200));
}
[HttpDelete]
[Authorize]
public async Task<ActionResult<MessageDto>> DeleteAccount([FromBody] DeleteAccountDto request)
{
var userId = HttpContext.GetUserId();
var result = await _userService.DeleteUserAsync(userId, request.Password);
if (!result.IsSuccess) return BadRequest(new ErrorDto(result.Errors ?? [], 400));
return Ok(new MessageDto(result.Value ?? "", 200));
}
}
}
@@ -0,0 +1,77 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Lockena.API.Middleware;
using Lockena.Application.DTO;
using Lockena.Application.DTO.VaultItem;
using Lockena.Application.Interfaces.Services;
using Lockena.Application.Services;
using Lockena.Domain.Entities;
namespace Lockena.API.Controllers
{
[Route("api/vault-items")]
[ApiController]
public class VaultItemsController : ControllerBase
{
private readonly IVaultService _vaultService;
public VaultItemsController(IVaultService vaultService)
{
_vaultService = vaultService;
}
[Authorize]
[HttpGet]
public async Task<ActionResult<GetVaultItemsDto>> Get([FromQuery] GetVaultItemsParamsDto request)
{
var userId = HttpContext.GetUserId();
var result = await _vaultService.GetAsync(userId, request);
if (!result.IsSuccess)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
return Ok(result.Value);
}
[Authorize]
[HttpGet("{id}")]
public async Task<ActionResult<VaultItemDto>> Get(Guid id)
{
var userId = HttpContext.GetUserId();
var result = await _vaultService.GetByIdAsync(userId, id);
if (!result.IsSuccess)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
return Ok(result.Value);
}
[Authorize]
[HttpPost]
public async Task<ActionResult<VaultItemDto>> Post([FromBody] CreateVaultItemDto request)
{
var userId = HttpContext.GetUserId();
var result = await _vaultService.CreateAsync(userId, request);
if (!result.IsSuccess)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
return Ok(result.Value);
}
[Authorize]
[HttpPut("{id}")]
public async Task<ActionResult<VaultItemDto>> Put(Guid id, [FromBody] CreateVaultItemDto request)
{
var userId = HttpContext.GetUserId();
var result = await _vaultService.UpdateAsync(userId, id, request);
if (!result.IsSuccess)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
return Ok(result.Value);
}
[Authorize]
[HttpDelete("{id}")]
public async Task<ActionResult<MessageDto>> Delete(Guid id)
{
var userId = HttpContext.GetUserId();
var result = await _vaultService.DeleteAsync(userId, id);
if (!result.IsSuccess)
return StatusCode(result.Status, new ErrorDto(result.Errors ?? [], result.Status));
return Ok(result.Value);
}
}
}
+96
View File
@@ -0,0 +1,96 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
<UserSecretsId>0b980db9-dd75-43c0-bcf4-30ff3020ec89</UserSecretsId>
<GenerateDocumentationFile>False</GenerateDocumentationFile>
<PublishAot>False</PublishAot>
<SignAssembly>False</SignAssembly>
<PublishTrimmed>False</PublishTrimmed>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>portable</DebugType>
<NoWarn></NoWarn>
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>portable</DebugType>
<NoWarn></NoWarn>
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\cs\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\de\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\es\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\fr\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\it\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\ja\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\ko\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\Microsoft.Build.Locator.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.exe" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.exe.config" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\Microsoft.IO.Redist.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\Newtonsoft.Json.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\pl\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\pt-BR\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\ru\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\System.Buffers.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\System.Collections.Immutable.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\System.CommandLine.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\System.Memory.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\System.Numerics.Vectors.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\System.Runtime.CompilerServices.Unsafe.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\System.Threading.Tasks.Extensions.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\tr\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\zh-Hans\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\zh-Hant\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\cs\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\de\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\es\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\fr\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\it\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\ja\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\ko\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\Microsoft.Build.Locator.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.deps.json" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll.config" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.runtimeconfig.json" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\Newtonsoft.Json.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\pl\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\pt-BR\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\ru\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\System.Collections.Immutable.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\System.CommandLine.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\tr\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\zh-Hans\System.CommandLine.resources.dll" />
<Content Remove="C:\Users\kinda\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-netcore\zh-Hant\System.CommandLine.resources.dll" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.3" />
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="10.1.3" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.3" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lockena.Application\Lockena.Application.csproj" />
<ProjectReference Include="..\Lockena.Infrastructure\Lockena.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,16 @@
namespace Lockena.API.Middleware
{
public static class HttpContextExtensions
{
public static Guid GetUserId(this HttpContext context)
{
Guid userId;
string? str = context.Items["UserId"]?.ToString();
if (str == null)
return Guid.Empty;
Guid.TryParse(str, out userId);
return userId;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
using System.Security.Claims;
namespace Lockena.API.Middleware
{
public class UserIdMiddleware
{
private readonly RequestDelegate _next;
public UserIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.User.Identity?.IsAuthenticated == true)
{
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier);
if (userId != null && userId.Value != null) context.Items["UserId"] = userId.Value;
}
await _next(context);
}
}
}
+140
View File
@@ -0,0 +1,140 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Lockena.API.Middleware;
using Lockena.Application.Common;
using Lockena.Application.Interfaces.Repository;
using Lockena.Application.Interfaces.Services;
using Lockena.Application.Services;
using Lockena.Infrastructure.Data;
using Lockena.Infrastructure.Data.Repositories;
using System.Threading.RateLimiting;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
//DbContext
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
//Repository
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
builder.Services.AddScoped<IVaultItemRepository, VaultItemRepository>();
builder.Services.AddScoped<IEmailTokenRepository, EmailTokenRepository>();
//Services
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ICryptoService, CryptoService>();
builder.Services.AddScoped<IJwtService, JwtService>();
builder.Services.AddScoped<IRefreshService, RefreshService>();
builder.Services.AddScoped<ITelegramService, TelegramService>();
builder.Services.AddScoped<ILogoService, LogoDevService>();
builder.Services.AddScoped<IMailService, ResendService>();
builder.Services.AddScoped<IVaultService, VaultService>();
//HttpClient And Cache
builder.Services.AddHttpClient();
builder.Services.AddMemoryCache();
//JWT
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("JwtSettings"));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
builder.Configuration["JwtSettings:SecretKey"]!)),
ValidateIssuer = true,
ValidIssuer = builder.Configuration["JwtSettings:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["JwtSettings:Audience"]
};
});
//Rate limiting
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
{
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
});
});
});
//CORS
var corsOrigins = builder.Configuration["AllowedCorsOrigins"]?.Split(';', StringSplitOptions.RemoveEmptyEntries) ?? [];
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowSPA", policy =>
policy.WithOrigins(corsOrigins)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
});
// Controllers
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var messages = context.ModelState
.SelectMany(x => x.Value.Errors)
.Select(x => x.ErrorMessage)
.ToArray();
var response = new { status = 400, errors = messages };
return new BadRequestObjectResult(response);
};
});
// Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
//Migrations
using (var scope = app.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
context.Database.Migrate();
}
//CORS
app.UseCors("AllowSPA");
app.UseCookiePolicy(new CookiePolicyOptions
{
MinimumSameSitePolicy = SameSiteMode.None,
HttpOnly = Microsoft.AspNetCore.CookiePolicy.HttpOnlyPolicy.Always,
Secure = CookieSecurePolicy.Always,
});
// Configure pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthentication();
app.UseMiddleware<UserIdMiddleware>();
app.UseAuthorization();
app.UseRateLimiter();
app.MapControllers();
app.Run();
+21
View File
@@ -0,0 +1,21 @@
{
"profiles": {
"https": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:5000"
},
"http": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5000"
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}
+24
View File
@@ -0,0 +1,24 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Username=postgres;Database=lockena;Password=TestDbPass"
},
"JwtSettings": {
"SecretKey": "<YourVeryVeryVeryVerySecretJWTKey>",
"Issuer": "LockenaAPI",
"Audience": "LockenaApplication",
"AccessTokenLifetimeMinutes": 15,
"RefreshTokenLifetimeDays": 7
},
"TelegramBotToken": "8640919797:AAHYoUU8al9TcZGTW79HvL14rQL5zWABmjo",
"LogoDevApiKey": "Api Key From logo.dev",
"ResendApiKey": "Api Key From resend.dev",
"LinkForEmailConfirmation": "https://localhost:5173/email-confirmation/",
"AllowedCorsOrigins": "https://localhost:5173;https://192.168.1.20:5173",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
}
}
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Lockena.Application.Common
{
public static class Base64Converter
{
public static byte[] Decode(string base64Url)
{
// Заменяем URL‑безопасные символы на обычные
string base64 = base64Url.Replace('-', '+').Replace('_', '/');
// Восстанавливаем padding до кратности 4
int padding = 4 - (base64.Length % 4);
if (padding is > 0 and < 4)
base64 = base64.PadRight(base64.Length + padding, '=');
return Convert.FromBase64String(base64);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Lockena.Application.Common
{
public class JwtSettings
{
public string SecretKey { get; set; } = string.Empty;
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
public int AccessTokenLifetimeMinutes { get; set; }
public int RefreshTokenLifetimeDays { get; set; }
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace Lockena.Application.Common
{
public class Result<T>
{
public bool IsSuccess { get; set; }
public T? Value { get; set; }
public string[]? Errors { get; set; }
public int Status { get; set; }
public static Result<T> Success(T value) =>
new() { IsSuccess = true, Value = value, Status = 200 };
public static Result<T> Failure(int status = 400, params string[]? errors) =>
new() { IsSuccess = false, Errors = errors, Status = status };
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Lockena.Application.DTO.Auth
{
public class AuthDto
{
public required string Email { get; set; }
public required string AccessToken { get; init; }
public required string RefreshToken { get; init; }
public required string EncryptedMasterKey { get; init; }
public required string Salt { get; init; }
}
}
@@ -0,0 +1,6 @@
namespace Lockena.Application.DTO.Auth;
public class ConfirmEmailDto
{
public string Token { get; set; }
}
@@ -0,0 +1,6 @@
namespace Lockena.Application.DTO.Auth;
public class LinkEmailDto
{
public string Email { get; set; }
}
@@ -0,0 +1,6 @@
namespace Lockena.Application.DTO.Auth;
public class SendEmailConfirmation
{
public string Credentials { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace Lockena.Application.DTO.Auth
{
public class SignInDto
{
[Required(ErrorMessage = "Email обязателен")]
[EmailAddress(ErrorMessage = "Неверный формат email")]
[MaxLength(100, ErrorMessage = "Email: максимум 100 символов")]
public string Email { get; set; } = string.Empty;
[Required(ErrorMessage = "Пароль обязателен")]
[MinLength(8, ErrorMessage = "Минимальная длина пароля 8 символов")]
[MaxLength(100, ErrorMessage = "Пароль: максимум 100 символов")]
public string Password { get; set; } = string.Empty;
}
}
+26
View File
@@ -0,0 +1,26 @@
using Lockena.Application.Common;
using System.ComponentModel.DataAnnotations;
namespace Lockena.Application.DTO.Auth
{
public class SignUpDto
{
[Required(ErrorMessage = "Email обязателен")]
[EmailAddress(ErrorMessage = "Неверный формат email")]
[MaxLength(100, ErrorMessage = "Email: максимум 100 символов")]
public required string Email { get; init; }
[Required(ErrorMessage = "Пароль обязателен")]
[MinLength(8, ErrorMessage = "Пароль минимум 8 символов")]
[MaxLength(100, ErrorMessage = "Пароль: максимум 100 символов")]
[RegularExpression(@"^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[^\w\s]).{8,100}$",
ErrorMessage = "Пароль должен содержать: заглавную букву, строчную букву, цифру, специальный символ")]
public required string Password { get; init; }
[Required(ErrorMessage = "Ключ обязателен")]
public required string EncryptedMasterKey { get; init; }
[Required(ErrorMessage = "Соль обязательна")]
public required string Salt { get; init; }
}
}
@@ -0,0 +1,11 @@
using Lockena.Application.Common;
using System.ComponentModel.DataAnnotations;
namespace Lockena.Application.DTO.Auth
{
public class TelegramSignInDto
{
[Required]
public string InitData { get; set; } = null!;
}
}
@@ -0,0 +1,25 @@
using Lockena.Application.Common;
using System.ComponentModel.DataAnnotations;
namespace Lockena.Application.DTO.Auth
{
public class TelegramSignUpDto
{
[Required]
public required string InitData { get; set; }
[Required(ErrorMessage = "Пароль обязателен")]
[MinLength(8, ErrorMessage = "Пароль минимум 8 символов")]
[MaxLength(100, ErrorMessage = "Пароль: максимум 100 символов")]
[RegularExpression(@"^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[^\w\s]).{8,100}$",
ErrorMessage = "Пароль должен содержать: заглавную букву, строчную букву, цифру, специальный символ")]
public required string Password { get; set; }
[Required(ErrorMessage = "Ключ обязателен")]
public required string EncryptedMasterKey { get; init; }
[Required(ErrorMessage = "Соль обязательна")]
public required string Salt { get; init; }
}
}
+18
View File
@@ -0,0 +1,18 @@
using Lockena.Application.Common;
using System;
using System.Collections.Generic;
using System.Text;
namespace Lockena.Application.DTO
{
public class ErrorDto
{
public string[] Errors { get; set; }
public int Status { get; set; }
public ErrorDto(string[] errors, int status)
{
Errors = errors; Status = status;
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Lockena.Application.DTO
{
public class MessageDto
{
public string Message { get; set; } = string.Empty;
public int Status { get; set; }
public MessageDto(string message, int status)
{
Message = message;
Status = status;
}
}
}
@@ -0,0 +1,24 @@
using Lockena.Application.Common;
using System.ComponentModel.DataAnnotations;
namespace Lockena.Application.DTO.User
{
public class ChangePasswordDto
{
[Required(ErrorMessage = "Текущий пароль обязателен")]
public string CurrentPassword { get; set; } = string.Empty;
[Required(ErrorMessage = "Новый пароль обязателен")]
[MinLength(8, ErrorMessage = "Пароль минимум 8 символов")]
[MaxLength(100, ErrorMessage = "Пароль: максимум 100 символов")]
[RegularExpression(@"^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[^\w\s]).{8,100}$",
ErrorMessage = "Пароль должен содержать: заглавную букву, строчную букву, цифру, специальный символ")]
public string NewPassword { get; set; } = string.Empty;
[Required(ErrorMessage = "Ключ обязателен")]
public required string EncryptedMasterKey { get; init; }
[Required(ErrorMessage = "Соль обязательна")]
public required string Salt { get; init; }
}
}
@@ -0,0 +1,7 @@
namespace Lockena.Application.DTO.User
{
public class DeleteAccountDto
{
public string Password { get; set; } = string.Empty;
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace Lockena.Application.DTO.User
{
public class ProfileDto
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public ProfileDto(Domain.Entities.User user)
{
Id = user.Id;
Email = user.Email;
CreatedAt = user.CreatedAtUtc;
UpdatedAt = user.UpdatedAtUtc;
}
}
}
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json.Serialization;
namespace Lockena.Application.DTO.User
{
public class TelegramUser
{
[property: JsonPropertyName("id")]
public long Id { get; set; }
[property: JsonPropertyName("first_name")]
public string? FirstName { get; set; }
[property: JsonPropertyName("last_name")]
public string? LastName { get; set; }
[property: JsonPropertyName("username")]
public string? Username { get; set; }
}
}
@@ -0,0 +1,17 @@
using Lockena.Application.Common;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text;
namespace Lockena.Application.DTO.VaultItem
{
public class CreateVaultItemDto
{
[Required]
public required string EncryptedItemKey { get; init; }
[Required]
[MaxLength(12000)]
public required string EncryptedPayload { get; init; }
}
}
@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Lockena.Application.DTO.VaultItem
{
public class GetVaultItemsDto
{
public ICollection<VaultItemDto> Items { get; set; } = new List<VaultItemDto>();
public int Total { get; set; }
public int PageSize { get; set; }
public int Page { get; set; }
}
}
@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Lockena.Application.DTO.VaultItem
{
public class GetVaultItemsParamsDto
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 1000;
}
}
@@ -0,0 +1,20 @@
namespace Lockena.Application.DTO.VaultItem
{
public class VaultItemDto
{
public Guid Id { get; set; }
public string EncryptedItemKey { get; set; }
public string EncryptedPayload { get; set; }
public DateTime CreatedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; }
public VaultItemDto(Domain.Entities.VaultItem item)
{
Id = item.Id;
EncryptedItemKey = item.EncryptedItemKey;
EncryptedPayload = item.EncryptedPayload;
CreatedAtUtc = item.CreatedAtUtc;
UpdatedAtUtc = item.UpdatedAtUtc;
}
}
}
@@ -0,0 +1,9 @@
using Lockena.Domain.Entities;
namespace Lockena.Application.Interfaces.Repository;
public interface IEmailTokenRepository
{
Task<EmailToken?> GetByHashAsync(string tokenHash);
Task AddAsync(EmailToken emailToken);
}
@@ -0,0 +1,14 @@
using Lockena.Domain.Entities;
namespace Lockena.Application.Interfaces.Repository
{
public interface IRefreshTokenRepository
{
Task<RefreshToken?> GetByHashAsync(string tokenHash);
Task<ICollection<RefreshToken>?> GetByUserAsync(Guid userId);
Task<bool> IsReusedAsync(string tokenHash);
Task AddAsync(RefreshToken refreshToken);
Task DeleteAsync(RefreshToken refreshToken);
Task DeleteAsync(ICollection<RefreshToken> refreshTokens);
}
}
@@ -0,0 +1,12 @@
namespace Lockena.Application.Interfaces.Repository
{
public interface IUnitOfWork : IDisposable
{
IUserRepository Users { get; }
IRefreshTokenRepository RefreshTokens { get; }
IVaultItemRepository VaultItems { get; }
IEmailTokenRepository EmailTokens { get; }
Task<int> SaveChangesAsync();
}
}
@@ -0,0 +1,16 @@
using Lockena.Domain.Entities;
namespace Lockena.Application.Interfaces.Repository
{
public interface IUserRepository
{
Task<User?> GetByIdAsync(Guid id);
Task<User?> GetByTelegramIdAsync(long telegramId);
Task<User?> GetByEmailAsync(string email);
Task<IEnumerable<User>> GetAllAsync();
Task AddAsync(User user);
Task UpdateAsync(User user);
Task DeleteAsync(User user);
Task<bool> IsEmailExistsAsync(string email);
}
}
@@ -0,0 +1,16 @@
using Lockena.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Text;
namespace Lockena.Application.Interfaces.Repository
{
public interface IVaultItemRepository
{
Task AddAsync(VaultItem vaultItem);
Task DeleteAsync(VaultItem vaultItem);
Task<VaultItem?> GetByIdAsync(Guid id, Guid userId);
Task<ICollection<VaultItem>> GetAsync(Guid userId);
Task<int> GetCountAsync(Guid userId);
}
}
@@ -0,0 +1,18 @@
using Lockena.Application.Common;
using Lockena.Application.DTO.Auth;
namespace Lockena.Application.Interfaces.Services
{
public interface IAuthService
{
Task<Result<AuthDto>> SignUpAsync(SignUpDto request, string fingerprint);
Task<Result<AuthDto>> SignInAsync(SignInDto request, string fingerprint);
Task<Result<AuthDto>> RefreshAsync(string refreshToken, string fingerprint);
Task<Result<AuthDto>> TelegramSignUpAsync(TelegramSignUpDto request, string fingerprint);
Task<Result<AuthDto>> TelegramSignInAsync(TelegramSignInDto request, string fingerprint);
Task<Result<string>> LogoutAsync(Guid userId, string fingerprint);
Task<Result<string>> ConfirmEmail(ConfirmEmailDto request);
Task<Result<string>> SendEmailConfirmation(SendEmailConfirmation request);
Task<Result<string>> TelegramLinkEmail(Guid userId, LinkEmailDto request);
}
}
@@ -0,0 +1,9 @@
namespace Lockena.Application.Interfaces.Services
{
public interface ICryptoService
{
string Hash(string text);
string Hash(string text, byte[] salt);
}
}
@@ -0,0 +1,7 @@
namespace Lockena.Application.Interfaces.Services
{
public interface IJwtService
{
string GenerateJwtToken(Guid userId);
}
}
@@ -0,0 +1,12 @@
using Lockena.Application.Common;
using System;
using System.Collections.Generic;
using System.Text;
namespace Lockena.Application.Interfaces.Services
{
public interface ILogoService
{
Task<Result<Stream>> GetLogoAsync(string service);
}
}
@@ -0,0 +1,9 @@
using Lockena.Domain.Entities;
namespace Lockena.Application.Interfaces.Services;
public interface IMailService
{
Task<bool> SendEmailAsync(Guid userId, string email);
Task<bool> VerifyEmailAsync(string token);
}
@@ -0,0 +1,16 @@
using Lockena.Application.Common;
using Lockena.Domain.Entities;
namespace Lockena.Application.Interfaces.Services
{
public interface IRefreshService
{
Task<string> GenerateAsync(User user, string fingerprint,
DateTime expires, string oldToken = "");
Task<Result<User>> CompareAsync(string refreshToken, string fingerprint);
Task<Result<string>> RevokeAsync(Guid userId, string fingerprint);
}
}
@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Lockena.Application.Interfaces.Services
{
public interface ITelegramService
{
bool Validate(string initData, string botToken);
(string dataCheckString, string? hash) ParseAuthString(string initData);
byte[] EncodeHmac(string message, byte[] key);
}
}
@@ -0,0 +1,18 @@
using Lockena.Application.Common;
using Lockena.Application.DTO.Auth;
using Lockena.Application.DTO.User;
using Lockena.Domain.Entities;
namespace Lockena.Application.Interfaces.Services
{
public interface IUserService
{
Task<Result<User>> CreateUserAsync(SignUpDto request);
Task<Result<User>> CreateTelegramUserAsync(TelegramUser user, TelegramSignUpDto request);
Task<Result<User>> GetUserAsync(string email, string password);
Task<Result<ProfileDto>> GetUserAsync(Guid userId);
Task<Result<string>> ChangePasswordAsync(Guid userId, ChangePasswordDto request);
Task<Result<string>> DeleteUserAsync(Guid userId, string password);
Task<Result<User>> GetUserByTelegramIdAsync(long telegramId);
}
}
@@ -0,0 +1,18 @@
using Lockena.Application.Common;
using Lockena.Application.DTO;
using Lockena.Application.DTO.VaultItem;
using System;
using System.Collections.Generic;
using System.Text;
namespace Lockena.Application.Interfaces.Services
{
public interface IVaultService
{
Task<Result<GetVaultItemsDto>> GetAsync(Guid userId, GetVaultItemsParamsDto request);
Task<Result<VaultItemDto>> GetByIdAsync(Guid userId, Guid id);
Task<Result<VaultItemDto>> CreateAsync(Guid userId, CreateVaultItemDto request);
Task<Result<VaultItemDto>> UpdateAsync(Guid userId, Guid itemId, CreateVaultItemDto request);
Task<Result<MessageDto>> DeleteAsync(Guid userId, Guid id);
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.3" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
<PackageReference Include="Resend" Version="0.2.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lockena.Domain\Lockena.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<None Remove="Templates\email.html" />
<EmbeddedResource Include="Templates\Email.html" />
</ItemGroup>
</Project>
+275
View File
@@ -0,0 +1,275 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Lockena.Application.Common;
using Lockena.Application.DTO.Auth;
using Lockena.Application.DTO.User;
using Lockena.Application.Interfaces.Services;
using Lockena.Domain.Entities;
using System.Text.Json;
using System.Web;
using Lockena.Application.Interfaces.Repository;
namespace Lockena.Application.Services
{
public class AuthService : IAuthService
{
private readonly IUserService _userService;
private readonly IJwtService _jwtService;
private readonly IRefreshService _refreshService;
private readonly IConfiguration _configuration;
private readonly ITelegramService _telegramService;
private readonly JwtSettings _jwtSettings;
private readonly IMailService _mailService;
private readonly IUnitOfWork _unitOfWork;
private readonly ICryptoService _cryptoService;
public AuthService(IUserService userService,
IJwtService jwtService,
IRefreshService refreshService,
IConfiguration configuration,
ITelegramService telegramService,
IOptions<JwtSettings> jwtSettings,
IUnitOfWork unitOfWork,
ICryptoService cryptoService,
IMailService mailService)
{
_userService = userService;
_jwtService = jwtService;
_refreshService = refreshService;
_configuration = configuration;
_telegramService = telegramService;
_jwtSettings = jwtSettings.Value;
_mailService = mailService;
_unitOfWork = unitOfWork;
_cryptoService = cryptoService;
}
public async Task<Result<AuthDto>> SignUpAsync(SignUpDto request, string fingerprint)
{
//Создаём пользователя
var user = await _userService.CreateUserAsync(request);
if (!user.IsSuccess || user.Value == null)
return Result<AuthDto>.Failure(user.Status, user.Errors);
//Генерируем access token
var accessToken = _jwtService.GenerateJwtToken(user.Value.Id);
//Генерируем refresh token
var refreshToken = await _refreshService.GenerateAsync(user.Value,
fingerprint, DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenLifetimeDays));
//Отправляем письмо с токеном
await _mailService.SendEmailAsync(user.Value.Id, user.Value.Email);
//Формируем ответ
var response = new AuthDto
{
AccessToken = accessToken,
RefreshToken = refreshToken,
Email = user.Value.Email,
EncryptedMasterKey = user.Value.EncryptedMasterKey,
Salt = user.Value.Salt,
};
return Result<AuthDto>.Success(response);
}
public async Task<Result<AuthDto>> SignInAsync(SignInDto request, string fingerprint)
{
//Получаем пользователя
var user = await _userService.GetUserAsync(request.Email, request.Password);
if (!user.IsSuccess || user.Value == null)
return Result<AuthDto>.Failure(user.Status, user.Errors);
if (!user.Value.EmailConfirmed)
{
await _mailService.SendEmailAsync(user.Value.Id, user.Value.Email);
return Result<AuthDto>.Failure(400, "Проверьте почту: мы отправили вам письмо для подтверждения.");
}
//Генерируем access token
var accessToken = _jwtService.GenerateJwtToken(user.Value.Id);
//Генерируем refresh token
var refreshToken = await _refreshService.GenerateAsync(user.Value,
fingerprint, DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenLifetimeDays));
//Формируем ответ
var response = new AuthDto
{
AccessToken = accessToken,
RefreshToken = refreshToken,
Email = user.Value.Email,
EncryptedMasterKey = user.Value.EncryptedMasterKey,
Salt = user.Value.Salt,
};
return Result<AuthDto>.Success(response);
}
public async Task<Result<AuthDto>> RefreshAsync(string refreshToken, string fingerprint)
{
//Получаем пользователя
var user = await _refreshService.CompareAsync(refreshToken, fingerprint);
if (!user.IsSuccess || user.Value == null)
return Result<AuthDto>.Failure(user.Status, user.Errors);
//Генерируем access token
var accessToken = _jwtService.GenerateJwtToken(user.Value.Id);
//Генерируем refresh token
refreshToken = await _refreshService.GenerateAsync(user.Value,
fingerprint, DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenLifetimeDays),
refreshToken);
//Формируем ответ
var response = new AuthDto
{
AccessToken = accessToken,
RefreshToken = refreshToken,
Email = user.Value.Email,
EncryptedMasterKey = user.Value.EncryptedMasterKey,
Salt = user.Value.Salt,
};
return Result<AuthDto>.Success(response);
}
public async Task<Result<string>> LogoutAsync(Guid userId, string fingerprint)
{
await _refreshService.RevokeAsync(userId, fingerprint);
return Result<string>.Success("Выход из системы выполнен успешно");
}
public async Task<Result<AuthDto>> TelegramSignUpAsync(TelegramSignUpDto request, string fingerprint)
{
//Получаем токен бота из конфигурации
var botToken = _configuration["TelegramBotToken"];
if (botToken == null || !_telegramService.Validate(request.InitData, botToken))
return Result<AuthDto>.Failure(400, "Неверные данные авторизации");
// Парсим данные и извлекаем информацию о пользователе
var query = HttpUtility.ParseQueryString(request.InitData);
var userJson = query["user"];
if (userJson == null)
return Result<AuthDto>.Failure(400, "Неверные данные авторизации");
var tgUser = JsonSerializer.Deserialize<TelegramUser>(userJson);
// Проверяем, существует ли пользователь с таким Telegram ID
if (tgUser == null)
return Result<AuthDto>.Failure(400, "Неверные данные авторизации");
var existingUser = await _userService.GetUserByTelegramIdAsync(tgUser.Id);
User user;
if (existingUser.IsSuccess && existingUser.Value != null)
user = existingUser.Value;
else
{
// Пользователь не существует, создаём нового
var userResult = await _userService.CreateTelegramUserAsync(tgUser, request);
if(!userResult.IsSuccess || userResult.Value == null)
return Result<AuthDto>.Failure(userResult.Status, userResult.Errors);
user = userResult.Value;
}
//Генерируем access token
var accessToken = _jwtService.GenerateJwtToken(user.Id);
//Генерируем refresh token
var refreshToken = await _refreshService.GenerateAsync(user,
fingerprint, DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenLifetimeDays));
//Формируем ответ
var response = new AuthDto
{
AccessToken = accessToken,
RefreshToken = refreshToken,
Email = user.Email,
EncryptedMasterKey = user.EncryptedMasterKey,
Salt = user.Salt,
};
return Result<AuthDto>.Success(response);
}
public async Task<Result<AuthDto>> TelegramSignInAsync(TelegramSignInDto request, string fingerprint)
{
//Получаем токен бота из конфигурации
var botToken = _configuration["TelegramBotToken"];
if (botToken == null || !_telegramService.Validate(request.InitData, botToken))
return Result<AuthDto>.Failure(400, "Неверные данные авторизации");
// Парсим данные и извлекаем информацию о пользователе
var query = HttpUtility.ParseQueryString(request.InitData);
var userJson = query["user"];
if (userJson == null)
return Result<AuthDto>.Failure(400, "Неверные данные авторизации");
var tgUser = JsonSerializer.Deserialize<TelegramUser>(userJson);
// Проверяем, существует ли пользователь с таким Telegram ID
if (tgUser == null)
return Result<AuthDto>.Failure(400, "Неверные данные авторизации");
var existingUser = await _userService.GetUserByTelegramIdAsync(tgUser.Id);
User user;
if (existingUser.IsSuccess && existingUser.Value != null)
user = existingUser.Value;
else
return Result<AuthDto>.Failure(404, "Пользователь с таким Telegram ID не найден");
//Генерируем access token
var accessToken = _jwtService.GenerateJwtToken(user.Id);
//Генерируем refresh token
var refreshToken = await _refreshService.GenerateAsync(user,
fingerprint, DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenLifetimeDays));
//Формируем ответ
var response = new AuthDto
{
AccessToken = accessToken,
RefreshToken = refreshToken,
Email = user.Email,
EncryptedMasterKey = user.EncryptedMasterKey,
Salt = user.Salt,
};
return Result<AuthDto>.Success(response);
}
public async Task<Result<string>> TelegramLinkEmail(Guid userId, LinkEmailDto request)
{
var user = await _unitOfWork.Users.GetByIdAsync(userId);
if (user == null) return Result<string>.Failure(400, "Не удалось найти пользователя");
if (await _unitOfWork.Users.IsEmailExistsAsync(request.Email))
return Result<string>.Failure(400, "Данный адрес электронной почты уже занят");
var sended = await _mailService.SendEmailAsync(user.Id, request.Email);
if (!sended) return Result<string>.Failure(400, $"Не удалось отправить письмо на почту: {user.Email}");
return Result<string>.Success("Ссылка для подтверждения почты успешно отправлена");
}
public async Task<Result<string>> ConfirmEmail(ConfirmEmailDto request)
{
var verified = await _mailService.VerifyEmailAsync(request.Token);
if (!verified) return Result<string>.Failure(400, "К сожалению, не удалось подтвердить ваш адрес электронной почты");
return Result<string>.Success("Ваш адрес электронной почты успешно подтвержден");
}
public async Task<Result<string>> SendEmailConfirmation(SendEmailConfirmation request)
{
User? user = await _unitOfWork.Users.GetByEmailAsync(request.Credentials);
if (user == null)
{
var token = await _unitOfWork.EmailTokens.GetByHashAsync(
_cryptoService.Hash(request.Credentials));
if (token == null) return Result<string>.Failure(400, "Не удалось найти пользователя");
user = await _unitOfWork.Users.GetByIdAsync(token.UserId);
}
if (user == null) return Result<string>.Failure(400, "Не удалось найти пользователя");
var sended = await _mailService.SendEmailAsync(user.Id, user.Email);
if (!sended) return Result<string>.Failure(400, $"Не удалось отправить письмо на почту: {user.Email}");
return Result<string>.Success("Ссылка для подтверждения почты успешно отправлена");
}
}
}
@@ -0,0 +1,29 @@
using Lockena.Application.Interfaces.Services;
using System.Security.Cryptography;
using System.Text;
namespace Lockena.Application.Services
{
public class CryptoService : ICryptoService
{
//Константы для хэширования
private const int KeySize = 32;
private const int Iterations = 100_000;
public string Hash(string token)
{
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(
Encoding.UTF8.GetBytes(token)
);
return Convert.ToBase64String(bytes);
}
public string Hash(string password, byte[] salt)
{
var pbkdf2 = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations,
HashAlgorithmName.SHA256, KeySize);
return Convert.ToBase64String(pbkdf2);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Lockena.Application.Common;
using Lockena.Application.Interfaces.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace Lockena.Application.Services
{
public class JwtService : IJwtService
{
private readonly JwtSettings _jwtSettings;
public JwtService(IOptions<JwtSettings> jwtSettings)
{
_jwtSettings = jwtSettings.Value;
}
public string GenerateJwtToken(Guid userId)
{
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>()
{
new(ClaimTypes.NameIdentifier, userId.ToString())
};
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenLifetimeMinutes),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
}
@@ -0,0 +1,35 @@
using Microsoft.Extensions.Configuration;
using Lockena.Application.Common;
using Lockena.Application.Interfaces.Services;
using System;
using System.Collections.Generic;
using System.Text;
namespace Lockena.Application.Services
{
public class LogoDevService : ILogoService
{
private readonly IConfiguration _configuration;
private readonly IHttpClientFactory _httpClientFactory;
public LogoDevService(IConfiguration configuration,
IHttpClientFactory httpClientFactory)
{
_configuration = configuration;
_httpClientFactory = httpClientFactory;
}
public async Task<Result<Stream>> GetLogoAsync(string service)
{
var apiKey = _configuration["LogoDevApiKey"];
var client = _httpClientFactory.CreateClient();
var url = $"https://img.logo.dev/name/{service}?token={apiKey}&format=png";
var response = await client.GetAsync(url);
if (!response.IsSuccessStatusCode)
return Result<Stream>.Failure(404, "Логотип не найден");
var logoData = await response.Content.ReadAsStreamAsync();
return Result<Stream>.Success(logoData);
}
}
}
@@ -0,0 +1,109 @@
using Lockena.Application.Common;
using Lockena.Application.Interfaces.Repository;
using Lockena.Application.Interfaces.Services;
using Lockena.Domain.Entities;
using System.Security.Cryptography;
namespace Lockena.Application.Services
{
public class RefreshService : IRefreshService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ICryptoService _cryptoService;
public RefreshService(IUnitOfWork unitOfWork, ICryptoService cryptoService)
{
_unitOfWork = unitOfWork;
_cryptoService = cryptoService;
}
public async Task<string> GenerateAsync(User user, string fingerprint,
DateTime expires, string oldToken = "")
{
//Делаем старый токен revoked
if (!string.IsNullOrEmpty(oldToken))
{
oldToken = _cryptoService.Hash(oldToken);
var old = await _unitOfWork.RefreshTokens.GetByHashAsync(oldToken);
if(old != null) old.IsRevoked = true;
}
//Генерируем новый токен
var bytes = new byte[64];
RandomNumberGenerator.Fill(bytes);
var refreshToken = Convert.ToHexString(bytes);
//Создаём модель
var token = new RefreshToken()
{
TokenHash = _cryptoService.Hash(refreshToken),
UserId = user.Id,
ExpiresAtUtc = expires,
CreatedAtUtc = DateTime.UtcNow,
IsRevoked = false,
FingerPrint = _cryptoService.Hash(fingerprint),
ReplacedBy = string.IsNullOrEmpty(oldToken) ? null : oldToken,
};
//Сохраняем в бд
await _unitOfWork.RefreshTokens.AddAsync(token);
await _unitOfWork.SaveChangesAsync();
return refreshToken;
}
public async Task<Result<User>> CompareAsync(string refreshToken, string fingerprint)
{
//Получаем текущий refresh token
var token = await _unitOfWork.RefreshTokens.GetByHashAsync(_cryptoService.Hash(refreshToken));
if (token == null)
return Result<User>.Failure(401);
//Получаем пользователя
var user = await _unitOfWork.Users.GetByIdAsync(token.UserId);
if (user == null)
return Result<User>.Failure(401);
//Проверяем fingerprint
if (fingerprint != "extension" && token.FingerPrint != _cryptoService.Hash(fingerprint))
return Result<User>.Failure(401);
//Проверяем срок действия
if (token.ExpiresAtUtc < DateTime.UtcNow)
return Result<User>.Failure(401);
//Если токен revoked и был заменён помечаем все токены revoked
if (token.IsRevoked && token.ReplacedBy != null)
{
var tokens = await _unitOfWork.RefreshTokens.GetByUserAsync(token.UserId);
if (tokens != null)
{
foreach (var t in tokens) t.IsRevoked = true;
await _unitOfWork.SaveChangesAsync();
}
return Result<User>.Failure(401);
}
if (token.IsRevoked)
return Result<User>.Failure(401);
return Result<User>.Success(user);
}
public async Task<Result<string>> RevokeAsync(Guid userId, string fingerprint)
{
//Получаем все токены пользователя
var tokens = await _unitOfWork.RefreshTokens.GetByUserAsync(userId);
if (tokens == null)
return Result<string>.Failure(400, "Для данного пользователя не найдены refresh token");
//Помечаем токены с тем же fingerprint revoked и сохраняем бд
var fingerprintHash = _cryptoService.Hash(fingerprint);
foreach (var token in tokens)
if (token.FingerPrint == fingerprintHash)
token.IsRevoked = true;
await _unitOfWork.SaveChangesAsync();
return Result<string>.Success("Refresh token пользователя были удалены");
}
}
}
@@ -0,0 +1,88 @@
using System.Reflection;
using System.Security.Cryptography;
using Lockena.Application.Interfaces.Repository;
using Lockena.Application.Interfaces.Services;
using Lockena.Domain.Entities;
using Microsoft.Extensions.Configuration;
using Resend;
namespace Lockena.Application.Services;
public class ResendService : IMailService
{
private readonly IResend _resend;
private const string TemplateName = "Lockena.Application.Templates.Email.html";
private readonly IConfiguration _configuration;
private readonly IUnitOfWork _unitOfWork;
private readonly ICryptoService _cryptoService;
public ResendService(IConfiguration configuration, IUnitOfWork unitOfWork, ICryptoService cryptoService)
{
_configuration = configuration;
_unitOfWork = unitOfWork;
_cryptoService = cryptoService;
var apiKey = configuration["ResendApiKey"];
if (string.IsNullOrEmpty(apiKey))
throw new ArgumentException("Resend Api Key is missing");
_resend = ResendClient.Create(apiKey);
}
private string GetTemplate(string token)
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream(TemplateName);
if (stream == null)
throw new FileNotFoundException($"Ресурс {TemplateName} не найден в сборке.");
using var reader = new StreamReader(stream);
var html = reader.ReadToEnd();
return html.Replace("{{VERIFICATION_LINK}}", _configuration["LinkForEmailConfirmation"] + token);
}
public async Task<bool> SendEmailAsync(Guid userId, string email)
{
var bytes = new byte[64];
RandomNumberGenerator.Fill(bytes);
var emailToken = Convert.ToHexString(bytes);
var mail = new EmailMessage();
mail.From = "onboarding@resend.dev";
mail.To.Add(email);
mail.Subject = "Подтверждение Email - Lockena";
mail.HtmlBody = GetTemplate(emailToken);
var send = await _resend.EmailSendAsync(mail);
if (send.Success)
{
var token = new EmailToken()
{
Id = Guid.NewGuid(),
UserId = userId,
NewEmail = email,
TokenHash = _cryptoService.Hash(emailToken),
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(10),
CreatedAtUtc = DateTime.UtcNow,
};
await _unitOfWork.EmailTokens.AddAsync(token);
await _unitOfWork.SaveChangesAsync();
}
return send.Success;
}
public async Task<bool> VerifyEmailAsync(string token)
{
var emailToken = await _unitOfWork.EmailTokens.GetByHashAsync(_cryptoService.Hash(token));
if (emailToken == null || emailToken.ExpiresAtUtc < DateTime.UtcNow || emailToken.IsRevoked)
return false;
var user = await _unitOfWork.Users.GetByIdAsync(emailToken.UserId);
if (user == null)
return false;
if (emailToken.NewEmail != null)
user.Email = emailToken.NewEmail;
user.EmailConfirmed = true;
emailToken.IsRevoked = true;
await _unitOfWork.SaveChangesAsync();
return true;
}
}
@@ -0,0 +1,42 @@
using Lockena.Application.Interfaces.Services;
using System.Security.Cryptography;
using System.Text;
using System.Web;
namespace Lockena.Application.Services
{
public class TelegramService : ITelegramService
{
public byte[] EncodeHmac(string message, byte[] key)
{
using var hmac = new HMACSHA256(key);
return hmac.ComputeHash(Encoding.UTF8.GetBytes(message));
}
public (string dataCheckString, string? hash) ParseAuthString(string initData)
{
var query = HttpUtility.ParseQueryString(initData);
var hash = query["hash"];
query.Remove("hash");
var parameters = query.AllKeys
.OrderBy(k => k)
.Select(k => $"{k}={query[k]}")
.ToList();
var dataCheckString = string.Join("\n", parameters);
return (dataCheckString, hash);
}
public bool Validate(string initData, string botToken)
{
try
{
var (dataCheckString, receivedHash) = ParseAuthString(initData);
var secretKey = EncodeHmac(botToken, Encoding.UTF8.GetBytes("WebAppData"));
var validationKeyBytes = EncodeHmac(dataCheckString, secretKey);
var calculatedHash = BitConverter.ToString(validationKeyBytes).Replace("-", "").ToLower();
return calculatedHash == receivedHash;
}
catch { return false; }
}
}
}
+158
View File
@@ -0,0 +1,158 @@
using Lockena.Application.Common;
using Lockena.Application.DTO.Auth;
using Lockena.Application.DTO.User;
using Lockena.Application.Interfaces.Repository;
using Lockena.Application.Interfaces.Services;
using Lockena.Domain.Entities;
namespace Lockena.Application.Services
{
public class UserService : IUserService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ICryptoService _cryptoService;
public UserService(IUnitOfWork unitOfWork, ICryptoService cryptoService)
{
_unitOfWork = unitOfWork;
_cryptoService = cryptoService;
}
public async Task<Result<User>> CreateUserAsync(SignUpDto request)
{
//Проверка уникальности email
var isEmailExists = await _unitOfWork.Users.IsEmailExistsAsync(request.Email);
if (isEmailExists)
return Result<User>.Failure(400, "Данный email адрес уже занят");
//Хэшируем пароль
var passwordHash = _cryptoService.Hash(request.Password, Base64Converter.Decode(request.Salt));
//Создаём модель
var user = new User()
{
Id = Guid.NewGuid(),
Email = request.Email.ToLower(),
EmailConfirmed = false,
PasswordHash = passwordHash,
Salt = request.Salt,
EncryptedMasterKey = request.EncryptedMasterKey,
CreatedAtUtc = DateTime.UtcNow,
UpdatedAtUtc = DateTime.UtcNow,
};
//Добавляем в бд и сохраняем
await _unitOfWork.Users.AddAsync(user);
await _unitOfWork.SaveChangesAsync();
return Result<User>.Success(user);
}
public async Task<Result<User>> CreateTelegramUserAsync(TelegramUser user, TelegramSignUpDto request)
{
//Инициализируем недостающие данные
var email = $"{(user.Username ?? user.Id.ToString())}@telegram";
var passwordHash = _cryptoService.Hash(request.Password, Base64Converter.Decode(request.Salt));
//Создаём модель
var newUser = new User()
{
Id = Guid.NewGuid(),
Email = email,
EmailConfirmed = false,
PasswordHash = passwordHash,
Salt = request.Salt,
EncryptedMasterKey = request.EncryptedMasterKey,
TelegramId = user.Id,
CreatedAtUtc = DateTime.UtcNow,
UpdatedAtUtc = DateTime.UtcNow,
};
//Добавляем в бд и сохраняем
await _unitOfWork.Users.AddAsync(newUser);
await _unitOfWork.SaveChangesAsync();
return Result<User>.Success(newUser);
}
public async Task<Result<User>> GetUserAsync(string email, string password)
{
//Получаем пользователя по почте
var user = await _unitOfWork.Users.GetByEmailAsync(email);
if (user == null)
return Result<User>.Failure(400, "Неверные учётные данные");
//Проверяем хэши паролей
var passwordHash = _cryptoService.Hash(password,
Base64Converter.Decode(user.Salt));
if (user.PasswordHash != passwordHash)
return Result<User>.Failure(400, "Неверные учётные данные");
return Result<User>.Success(user);
}
public async Task<Result<ProfileDto>> GetUserAsync(Guid userId)
{
//Получаем пользователя
var user = await _unitOfWork.Users.GetByIdAsync(userId);
if (user == null)
return Result<ProfileDto>.Failure(404, "Запрошенный пользователь не найден");
return Result<ProfileDto>.Success(new ProfileDto(user));
}
public async Task<Result<string>> ChangePasswordAsync(Guid userId, ChangePasswordDto request)
{
//Получаем пользователя
var user = await _unitOfWork.Users.GetByIdAsync(userId);
if (user == null)
return Result<string>.Failure(404, "Запрошенный пользователь не найден");
//Проверка старого пароля
if (user.PasswordHash != _cryptoService.Hash(request.CurrentPassword,
Base64Converter.Decode(user.Salt)))
return Result<string>.Failure(400, "Неверный пароль");
//Хэшируем новый пароль и присваеваем его пользователю
user.EncryptedMasterKey = request.EncryptedMasterKey;
user.Salt = request.Salt;
var password = _cryptoService.Hash(request.NewPassword, Base64Converter.Decode(request.Salt));
user.PasswordHash = password;
//Обновляем пользователя и сохраняем в бд
await _unitOfWork.Users.UpdateAsync(user);
await _unitOfWork.SaveChangesAsync();
return Result<string>.Success("Пароль был успешно изменён");
}
public async Task<Result<string>> DeleteUserAsync(Guid userId, string password)
{
//Получаем пользователя
var user = await _unitOfWork.Users.GetByIdAsync(userId);
if (user == null)
return Result<string>.Failure(404, "Запрошенный пользователь не найден");
//Проверка пароля
if (user.PasswordHash != _cryptoService.Hash(password,
Base64Converter.Decode(user.Salt)))
return Result<string>.Failure(400, "Неверный пароль");
//Удаляем пользователя и сохраняем бд
await _unitOfWork.Users.DeleteAsync(user);
await _unitOfWork.SaveChangesAsync();
return Result<string>.Success("Аккаунт был успешно удалён");
}
public async Task<Result<User>> GetUserByTelegramIdAsync(long telegramId)
{
//Получаем пользователя
var user = await _unitOfWork.Users.GetByTelegramIdAsync(telegramId);
if (user == null)
return Result<User>.Failure(404, "Запрошенный пользователь не найден");
return Result<User>.Success(user);
}
}
}
@@ -0,0 +1,95 @@
using Lockena.Application.Common;
using Lockena.Application.DTO;
using Lockena.Application.DTO.VaultItem;
using Lockena.Application.Interfaces.Repository;
using Lockena.Application.Interfaces.Services;
using Lockena.Domain.Entities;
namespace Lockena.Application.Services
{
public class VaultService : IVaultService
{
private readonly IUnitOfWork _unitOfWork;
public VaultService(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork;
public async Task<Result<GetVaultItemsDto>> GetAsync(Guid userId, GetVaultItemsParamsDto request)
{
var items = await _unitOfWork.VaultItems.GetAsync(userId);
//Пагинация
int pageSize = Math.Clamp(request.PageSize, 1, 1000);
var itemsDto =
items
.Select(i => new VaultItemDto(i))
.OrderByDescending(i => i.UpdatedAtUtc)
.Skip((request.Page - 1) * pageSize)
.Take(pageSize)
.ToList();
var response = new GetVaultItemsDto()
{
Items = itemsDto,
Page = request.Page,
PageSize = pageSize,
Total = items.Count
};
return Result<GetVaultItemsDto>.Success(response);
}
public async Task<Result<VaultItemDto>> GetByIdAsync(Guid userId, Guid id)
{
var item = await _unitOfWork.VaultItems.GetByIdAsync(userId, id);
if (item == null)
return Result<VaultItemDto>.Failure(404, "Item not fount");
return Result<VaultItemDto>.Success(new VaultItemDto(item));
}
public async Task<Result<VaultItemDto>> CreateAsync(Guid userId, CreateVaultItemDto request)
{
var count = await _unitOfWork.VaultItems.GetCountAsync(userId);
if (count == 1000)
return Result<VaultItemDto>.Failure(403, "Достигнут лимит в 1000 записей");
var item = new VaultItem
{
Id = Guid.NewGuid(),
UserId = userId,
EncryptedItemKey = request.EncryptedItemKey,
EncryptedPayload = request.EncryptedPayload,
CreatedAtUtc = DateTime.UtcNow,
UpdatedAtUtc = DateTime.UtcNow,
};
await _unitOfWork.VaultItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
return Result<VaultItemDto>.Success(new VaultItemDto(item));
}
public async Task<Result<MessageDto>> DeleteAsync(Guid userId, Guid id)
{
var item = await _unitOfWork.VaultItems.GetByIdAsync(id, userId);
if (item == null) return Result<MessageDto>.Failure(404, "Item not found");
await _unitOfWork.VaultItems.DeleteAsync(item);
await _unitOfWork.SaveChangesAsync();
return Result<MessageDto>.Success(new MessageDto("Item deleted", 200));
}
public async Task<Result<VaultItemDto>> UpdateAsync(Guid userId, Guid itemId, CreateVaultItemDto request)
{
var item = await _unitOfWork.VaultItems.GetByIdAsync(itemId, userId);
if (item == null) return Result<VaultItemDto>.Failure(404, "Item not found");
item.UpdatedAtUtc = DateTime.UtcNow;
item.EncryptedItemKey = request.EncryptedItemKey;
item.EncryptedPayload = request.EncryptedPayload;
await _unitOfWork.SaveChangesAsync();
return Result<VaultItemDto>.Success(new VaultItemDto(item));
}
}
}
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Подтверждение Email - Lockena</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f3f4f6;">
<table role="presentation" cellpadding="0" cellspacing="0" style="width: 100%; background-color: #f3f4f6;">
<tr>
<td style="padding: 40px 20px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
<tr>
<td style="background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); padding: 40px 40px 30px; text-align: center; border-radius: 12px 12px 0 0;">
<div style="display: inline-block; width: 60px; height: 60px; background-color: rgba(255, 255, 255, 0.2); border-radius: 50%; margin-bottom: 20px;">
<svg width="60" height="60" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_29_1421)">
<circle cx="512" cy="512" r="512" fill="white"/>
<path d="M504.049 844.892C500.043 843.714 487.439 839.119 475.188 834.17C458.342 827.336 427.597 811.194 407.453 798.705C394.966 790.81 368.815 772.194 365.045 768.424C364.692 768.07 360.451 764.418 355.621 760.294C343.841 750.161 320.046 725.889 314.038 717.759C311.447 714.224 307.088 708.686 304.379 705.387C301.669 702.206 296.368 694.783 292.599 688.892C288.829 683.118 284.353 676.167 282.586 673.575C276.814 665.091 261.971 633.75 258.437 622.91C257.612 620.318 255.61 614.662 253.961 610.303C249.838 599.345 244.183 575.191 241.356 556.928C239.353 543.614 239 535.719 239 497.426C239 450.768 239.471 445.583 244.772 435.215C250.662 423.668 260.793 418.012 301.434 403.52C311.447 399.985 320.046 396.45 320.635 395.743C321.106 394.919 321.224 390.323 320.871 385.493C319.221 365.109 323.933 326.462 330.294 307.61C336.656 288.994 347.14 268.492 357.271 254.825C365.163 244.221 383.304 225.604 394.495 216.767C413.225 201.804 440.555 188.489 464.586 182.48C494.036 175.057 533.145 175.175 561.77 182.834C623.379 199.329 673.68 246.577 693.588 306.432C696.297 314.562 699.242 325.873 700.302 331.764C702.423 344.254 703.954 379.601 702.423 379.601C701.834 379.601 697.946 378.777 693.706 377.716C679.57 374.417 655.539 369.94 637.162 367.348C618.903 364.638 618.903 364.638 618.078 357.097C616.076 338.952 608.89 319.628 599.113 306.55C568.956 265.9 516.064 251.997 468.12 272.027C457.047 276.622 448.683 282.16 438.317 291.822C422.885 306.196 412.636 323.87 407.335 345.432C404.508 356.625 404.508 356.625 404.508 517.456C404.508 678.288 404.508 678.288 407.571 685.239C417.702 708.097 447.269 723.414 468.12 716.581C473.421 714.931 511.823 698.671 518.184 695.372L549.048 682.058C555.762 679.112 562.83 676.049 564.715 675.342C572.49 672.396 579.087 667.094 582.503 660.849C585.919 654.723 585.919 654.723 586.272 620.082C586.626 585.441 586.626 585.441 592.398 580.257C615.369 559.638 609.126 519.931 580.854 506.027L580.848 506.025C575.198 503.317 572.369 501.961 562.359 501.608C552.559 501.258 549.576 502.354 543.912 504.435L543.747 504.496C519.598 513.568 508.289 539.372 517.478 563.997C520.776 572.599 528.315 581.789 535.03 585.324C538.21 587.091 538.21 587.091 537.975 610.185C537.621 633.396 537.621 633.396 534.676 634.928C533.027 635.753 525.135 639.287 517.006 642.822C508.878 646.357 493.8 653.073 483.434 657.786C462.583 667.094 460.698 667.683 456.34 666.623C449.625 664.973 449.861 669.686 449.861 522.994C449.861 388.085 449.861 388.085 453.159 386.789C461.759 383.372 499.69 382.311 541.155 384.314C629.269 388.674 687.109 397.629 752.605 417.07C763.914 420.369 769.333 423.55 775.223 430.148C784.764 440.752 785 442.284 785 498.015C785 539.018 784.647 548.327 782.88 559.638C779.11 582.967 771.807 609.124 762.5 632.925C754.843 652.602 726.807 699.732 716.441 710.454C715.498 711.396 713.378 713.988 711.729 716.345C699.595 732.723 660.722 769.131 640.107 783.505C636.219 786.215 630.683 790.103 627.738 792.106C602.882 809.78 546.221 837.587 521.129 844.538C514.415 846.424 509.939 846.424 504.049 844.892Z" fill="#4F39F6"/>
</g>
<defs>
<clipPath id="clip0_29_1421">
<rect width="1024" height="1024" fill="white"/>
</clipPath>
</defs>
</svg>
</div>
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700; letter-spacing: -0.5px;">Lockena</h1>
<p style="margin: 10px 0 0; color: rgba(255, 255, 255, 0.9); font-size: 14px;">Безопасное управление паролями</p>
</td>
</tr>
<tr>
<td style="padding: 40px;">
<h2 style="margin: 0 0 20px; color: #111827; font-size: 24px; font-weight: 600;">Подтверждение email</h2>
<p style="margin: 0 0 24px; color: #4b5563; font-size: 16px; line-height: 1.6;">
Здравствуйте! Чтобы завершить регистрацию в Lockena, нажмите на кнопку ниже для подтверждения вашего email адреса:
</p>
<!-- Verification Button -->
<table role="presentation" cellpadding="0" cellspacing="0" style="width: 100%; margin: 32px 0;">
<tr>
<td style="text-align: center;">
<a href="{{VERIFICATION_LINK}}" style="display: inline-block; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: #ffffff; text-decoration: none; padding: 16px 48px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 6px rgba(79, 70, 229, 0.3);">
Подтвердить email
</a>
</td>
</tr>
</table>
<p style="margin: 24px 0 0; color: #6b7280; font-size: 14px; line-height: 1.6; text-align: center;">
Ссылка действительна в течение <strong style="color: #4f46e5;">10 минут</strong>
</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="width: 100%; margin: 24px 0 0; padding: 20px; background-color: #f9fafb; border-radius: 8px;">
<tr>
<td>
<p style="margin: 0 0 8px; color: #6b7280; font-size: 13px; line-height: 1.5;">
Если кнопка не работает, скопируйте и вставьте эту ссылку в браузер:
</p>
<p style="margin: 0; color: #4f46e5; font-size: 13px; word-break: break-all; line-height: 1.5;">
{{VERIFICATION_LINK}}
</p>
</td>
</tr>
</table>
<p style="margin: 24px 0 0; color: #4b5563; font-size: 16px; line-height: 1.6;">
Если вы не регистрировались в Lockena, просто проигнорируйте это письмо.
</p>
</td>
</tr>
<tr>
<td style="padding: 0 40px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="width: 100%; background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 8px; padding: 16px 20px;">
<tr>
<td>
<p style="margin: 0; color: #92400e; font-size: 14px; line-height: 1.5;">
<strong style="display: block; margin-bottom: 4px;">⚠️ Важно</strong>
Никогда не переходите по подозрительным ссылкам. Убедитесь, что ссылка ведет на официальный домен Lockena.
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="background-color: #f9fafb; padding: 32px 40px; border-radius: 0 0 12px 12px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 12px; color: #6b7280; font-size: 14px; text-align: center;">
Нужна помощь? <a href="mailto:support@lockena.com" style="color: #4f46e5; text-decoration: none; font-weight: 500;">support@lockena.com</a>
</p>
<p style="margin: 0; color: #9ca3af; font-size: 12px; text-align: center; line-height: 1.6;">
© 2026 Lockena. Все права защищены.<br>
Это автоматическое письмо, пожалуйста, не отвечайте на него.
</p>
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" style="max-width: 600px; margin: 20px auto 0;">
<tr>
<td style="padding: 0 20px; text-align: center;">
<p style="margin: 0; color: #9ca3af; font-size: 12px; line-height: 1.6;">
Если у вас возникли проблемы с отображением этого письма,
<a href="#" style="color: #4f46e5; text-decoration: none;">откройте его в браузере</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<Solution>
<Folder Name="/Элементы решения/">
<File Path="README.md" />
</Folder>
<Project Path="Lockena.API/Lockena.API.csproj" Id="17eacfb8-e85e-4770-9965-5f0e59fa0373">
<BuildDependency Project="Lockena.Application/Lockena.Application.csproj" />
</Project>
<Project Path="Lockena.Application/Lockena.Application.csproj" Id="1f1b89a7-0ef7-4db5-9f5a-24dcc8eb0e4b">
<BuildDependency Project="Lockena.Domain/Lockena.Domain.csproj" />
</Project>
<Project Path="Lockena.Domain/Lockena.Domain.csproj" Id="346c4e6a-d4de-46bc-befb-316cf06918a7" />
<Project Path="Lockena.Infrastructure/Lockena.Infrastructure.csproj" Id="3b5af8f4-1356-43d0-9e26-d33262030cb9">
<BuildDependency Project="Lockena.Application/Lockena.Application.csproj" />
<BuildDependency Project="Lockena.Domain/Lockena.Domain.csproj" />
</Project>
</Solution>
@@ -0,0 +1,12 @@
namespace Lockena.Domain.Entities;
public class EmailToken
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string? NewEmail { get; set; }
public string TokenHash { get; set; }
public bool IsRevoked { get; set; }
public DateTime ExpiresAtUtc { get; set; }
public DateTime CreatedAtUtc { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
namespace Lockena.Domain.Entities
{
public class RefreshToken
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string TokenHash { get; set; } = null!;
public string FingerPrint { get; set; } = null!;
public DateTime ExpiresAtUtc { get; set; }
public DateTime CreatedAtUtc { get; set; }
public bool IsRevoked { get; set; }
public string? ReplacedBy { get; set; }
public User User { get; set; } = null!;
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace Lockena.Domain.Entities
{
public class User
{
public required Guid Id { get; set; }
public required string Email { get; set; }
public required bool EmailConfirmed { get; set; }
public required string PasswordHash { get; set; }
public required string Salt { get; set; }
public required string EncryptedMasterKey { get; set; }
public long? TelegramId { get; set; }
public required DateTime CreatedAtUtc { get; set; }
public required DateTime UpdatedAtUtc { get; set; }
public virtual ICollection<VaultItem> VaultItems { get; set; } = new List<VaultItem>();
public virtual ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}
}
+16
View File
@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Lockena.Domain.Entities
{
public class VaultItem
{
public required Guid Id { get; set; }
public required Guid UserId { get; set; }
public required string EncryptedItemKey { get; set; }
public required string EncryptedPayload { get; set; }
public required DateTime CreatedAtUtc { get; set; }
public required DateTime UpdatedAtUtc { get; set; }
}
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Lockena.Domain.Entities;
using System.Reflection.Metadata;
namespace Lockena.Infrastructure.Data
{
public class ApplicationDbContext : DbContext
{
public DbSet<User> Users { get; set; }
public DbSet<VaultItem> VaultItems { get; set; }
public DbSet<RefreshToken> RefreshTokens { get; set; }
public DbSet<EmailToken> EmailTokens { get; set; }
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<VaultItem>()
.HasIndex(i => i.UserId);
}
}
}
@@ -0,0 +1,25 @@
using Lockena.Application.Interfaces.Repository;
using Lockena.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Lockena.Infrastructure.Data.Repositories;
public class EmailTokenRepository : IEmailTokenRepository
{
private readonly DbSet<EmailToken> _emailTokens;
public EmailTokenRepository(ApplicationDbContext context)
{
_emailTokens = context.EmailTokens;
}
public async Task<EmailToken?> GetByHashAsync(string tokenHash)
{
return await _emailTokens.FirstOrDefaultAsync(t => t.TokenHash == tokenHash);
}
public async Task AddAsync(EmailToken emailToken)
{
await _emailTokens.AddAsync(emailToken);
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
using Lockena.Application.Interfaces.Repository;
using Lockena.Domain.Entities;
using Lockena.Infrastructure.Data;
namespace Lockena.Infrastructure.Data.Repositories
{
public class RefreshTokenRepository : IRefreshTokenRepository
{
private readonly DbSet<RefreshToken> _refreshTokens;
public RefreshTokenRepository(ApplicationDbContext context)
{
_refreshTokens = context.RefreshTokens;
}
public async Task<RefreshToken?> GetByHashAsync(string tokenHash)
{
return await _refreshTokens.FirstOrDefaultAsync(t => t.TokenHash == tokenHash);
}
public async Task<ICollection<RefreshToken>?> GetByUserAsync(Guid userId)
{
return await _refreshTokens.Where(t => t.UserId == userId).ToListAsync();
}
public async Task AddAsync(RefreshToken refreshToken)
{
await _refreshTokens.AddAsync(refreshToken);
}
public Task DeleteAsync(RefreshToken refreshToken)
{
_refreshTokens.Remove(refreshToken);
return Task.CompletedTask;
}
public Task DeleteAsync(ICollection<RefreshToken> refreshTokens)
{
_refreshTokens.RemoveRange(refreshTokens);
return Task.CompletedTask;
}
public async Task<bool> IsReusedAsync(string tokenHash)
{
return await _refreshTokens.AnyAsync(t => t.ReplacedBy == tokenHash);
}
}
}
@@ -0,0 +1,39 @@
using Lockena.Application.Interfaces.Repository;
using Lockena.Infrastructure.Data;
namespace Lockena.Infrastructure.Data.Repositories
{
public class UnitOfWork : IUnitOfWork
{
private readonly ApplicationDbContext _context;
public IUserRepository Users { get; }
public IRefreshTokenRepository RefreshTokens { get; }
public IVaultItemRepository VaultItems { get; }
public IEmailTokenRepository EmailTokens { get; }
public UnitOfWork(
ApplicationDbContext context,
IUserRepository userRepository,
IRefreshTokenRepository refreshTokenRepository,
IEmailTokenRepository emailTokenRepository,
IVaultItemRepository vaultItems)
{
_context = context;
Users = userRepository;
RefreshTokens = refreshTokenRepository;
VaultItems = vaultItems;
EmailTokens = emailTokenRepository;
}
public async Task<int> SaveChangesAsync()
{
return await _context.SaveChangesAsync();
}
public void Dispose()
{
_context.Dispose();
}
}
}
@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using Lockena.Application.Interfaces.Repository;
using Lockena.Domain.Entities;
using Lockena.Infrastructure.Data;
namespace Lockena.Infrastructure.Data.Repositories
{
public class UserRepository : IUserRepository
{
private readonly DbSet<User> _users;
public UserRepository(ApplicationDbContext context)
{
_users = context.Users;
}
public async Task AddAsync(User user)
{
await _users.AddAsync(user);
}
public async Task<IEnumerable<User>> GetAllAsync()
{
return await _users.ToListAsync();
}
public async Task<User?> GetByEmailAsync(string email)
{
return await _users.FirstOrDefaultAsync(u => u.Email == email);
}
public async Task<User?> GetByIdAsync(Guid id)
{
return await _users.FirstOrDefaultAsync(u => u.Id == id);
}
public Task DeleteAsync(User user)
{
_users.Remove(user);
return Task.CompletedTask;
}
public Task UpdateAsync(User user)
{
_users.Update(user);
return Task.CompletedTask;
}
public async Task<bool> IsEmailExistsAsync(string email)
{
return await _users.AnyAsync(u => u.Email.ToLower() == email.ToLower());
}
public async Task<User?> GetByTelegramIdAsync(long telegramId)
{
return await _users.FirstOrDefaultAsync(u => u.TelegramId == telegramId);
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using Lockena.Application.Interfaces.Repository;
using Lockena.Domain.Entities;
namespace Lockena.Infrastructure.Data.Repositories
{
public class VaultItemRepository : IVaultItemRepository
{
private readonly DbSet<VaultItem> _vaultItems;
public VaultItemRepository(ApplicationDbContext context) => _vaultItems = context.VaultItems;
public async Task AddAsync(VaultItem vaultItem)
{
await _vaultItems.AddAsync(vaultItem);
}
public Task DeleteAsync(VaultItem vaultItem)
{
_vaultItems.Remove(vaultItem);
return Task.CompletedTask;
}
public async Task<ICollection<VaultItem>> GetAsync(Guid userId)
{
return await _vaultItems.Where(v => v.UserId == userId).ToListAsync();
}
public async Task<VaultItem?> GetByIdAsync(Guid id, Guid userId)
{
return await _vaultItems.FirstOrDefaultAsync(v => v.Id == id && v.UserId == userId);
}
public async Task<int> GetCountAsync(Guid userId)
{
return await _vaultItems.Where(v => v.UserId == userId).CountAsync();
}
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lockena.Application\Lockena.Application.csproj" />
<ProjectReference Include="..\Lockena.Domain\Lockena.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,177 @@
// <auto-generated />
using System;
using Lockena.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lockena.Infrastructure.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260225133920_InitialDb")]
partial class InitialDb
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("FingerPrint")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("ReplacedBy")
.HasColumnType("text");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EncryptedMasterKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MasterKeyIv")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Salt")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TelegramId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BlindIndex")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedItemKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EncryptedPayload")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ItemKeyIv")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PayloadIv")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("BlindIndex");
b.HasIndex("UserId");
b.ToTable("VaultItems");
});
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.HasOne("Lockena.Domain.Entities.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.HasOne("Lockena.Domain.Entities.User", null)
.WithMany("VaultItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Navigation("RefreshTokens");
b.Navigation("VaultItems");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,111 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lockena.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialDb : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Email = table.Column<string>(type: "text", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: false),
Salt = table.Column<string>(type: "text", nullable: false),
EncryptedMasterKey = table.Column<string>(type: "text", nullable: false),
MasterKeyIv = table.Column<string>(type: "text", nullable: false),
TelegramId = table.Column<long>(type: "bigint", nullable: true),
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RefreshTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "text", nullable: false),
FingerPrint = table.Column<string>(type: "text", nullable: false),
ExpiresAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsRevoked = table.Column<bool>(type: "boolean", nullable: false),
ReplacedBy = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
table.ForeignKey(
name: "FK_RefreshTokens_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VaultItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
EncryptedItemKey = table.Column<string>(type: "text", nullable: false),
ItemKeyIv = table.Column<string>(type: "text", nullable: false),
EncryptedPayload = table.Column<string>(type: "text", nullable: false),
PayloadIv = table.Column<string>(type: "text", nullable: false),
BlindIndex = table.Column<string>(type: "text", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VaultItems", x => x.Id);
table.ForeignKey(
name: "FK_VaultItems_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_UserId",
table: "RefreshTokens",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_VaultItems_BlindIndex",
table: "VaultItems",
column: "BlindIndex");
migrationBuilder.CreateIndex(
name: "IX_VaultItems_UserId",
table: "VaultItems",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RefreshTokens");
migrationBuilder.DropTable(
name: "VaultItems");
migrationBuilder.DropTable(
name: "Users");
}
}
}
@@ -0,0 +1,204 @@
// <auto-generated />
using System;
using Lockena.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lockena.Infrastructure.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260330205725_EmailConfirmation")]
partial class EmailConfirmation
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lockena.Domain.Entities.EmailToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("EmailTokens");
});
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("FingerPrint")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("ReplacedBy")
.HasColumnType("text");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("EncryptedMasterKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MasterKeyIv")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Salt")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TelegramId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BlindIndex")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedItemKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EncryptedPayload")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ItemKeyIv")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PayloadIv")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("BlindIndex");
b.HasIndex("UserId");
b.ToTable("VaultItems");
});
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.HasOne("Lockena.Domain.Entities.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.HasOne("Lockena.Domain.Entities.User", null)
.WithMany("VaultItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Navigation("RefreshTokens");
b.Navigation("VaultItems");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lockena.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class EmailConfirmation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "EmailConfirmed",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "EmailTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "text", nullable: false),
ExpiresAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EmailTokens", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmailTokens");
migrationBuilder.DropColumn(
name: "EmailConfirmed",
table: "Users");
}
}
}
@@ -0,0 +1,207 @@
// <auto-generated />
using System;
using Lockena.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lockena.Infrastructure.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260330220742_RevokeEmailTokens")]
partial class RevokeEmailTokens
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lockena.Domain.Entities.EmailToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("EmailTokens");
});
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("FingerPrint")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("ReplacedBy")
.HasColumnType("text");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("EncryptedMasterKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MasterKeyIv")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Salt")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TelegramId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BlindIndex")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedItemKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EncryptedPayload")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ItemKeyIv")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PayloadIv")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("BlindIndex");
b.HasIndex("UserId");
b.ToTable("VaultItems");
});
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.HasOne("Lockena.Domain.Entities.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.HasOne("Lockena.Domain.Entities.User", null)
.WithMany("VaultItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Navigation("RefreshTokens");
b.Navigation("VaultItems");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lockena.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class RevokeEmailTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsRevoked",
table: "EmailTokens",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsRevoked",
table: "EmailTokens");
}
}
}
@@ -0,0 +1,210 @@
// <auto-generated />
using System;
using Lockena.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lockena.Infrastructure.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260401155834_NewEmail")]
partial class NewEmail
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lockena.Domain.Entities.EmailToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("NewEmail")
.HasColumnType("text");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("EmailTokens");
});
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("FingerPrint")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("ReplacedBy")
.HasColumnType("text");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("EncryptedMasterKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MasterKeyIv")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Salt")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TelegramId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BlindIndex")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedItemKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EncryptedPayload")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ItemKeyIv")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PayloadIv")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("BlindIndex");
b.HasIndex("UserId");
b.ToTable("VaultItems");
});
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.HasOne("Lockena.Domain.Entities.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.HasOne("Lockena.Domain.Entities.User", null)
.WithMany("VaultItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Navigation("RefreshTokens");
b.Navigation("VaultItems");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lockena.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class NewEmail : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "NewEmail",
table: "EmailTokens",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NewEmail",
table: "EmailTokens");
}
}
}
@@ -0,0 +1,192 @@
// <auto-generated />
using System;
using Lockena.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lockena.Infrastructure.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260403140823_Refactor")]
partial class Refactor
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lockena.Domain.Entities.EmailToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("NewEmail")
.HasColumnType("text");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("EmailTokens");
});
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("FingerPrint")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("ReplacedBy")
.HasColumnType("text");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("EncryptedMasterKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Salt")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TelegramId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedItemKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EncryptedPayload")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("VaultItems");
});
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.HasOne("Lockena.Domain.Entities.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.HasOne("Lockena.Domain.Entities.User", null)
.WithMany("VaultItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Navigation("RefreshTokens");
b.Navigation("VaultItems");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lockena.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class Refactor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_VaultItems_BlindIndex",
table: "VaultItems");
migrationBuilder.DropColumn(
name: "BlindIndex",
table: "VaultItems");
migrationBuilder.DropColumn(
name: "ItemKeyIv",
table: "VaultItems");
migrationBuilder.DropColumn(
name: "PayloadIv",
table: "VaultItems");
migrationBuilder.DropColumn(
name: "MasterKeyIv",
table: "Users");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BlindIndex",
table: "VaultItems",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "ItemKeyIv",
table: "VaultItems",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "PayloadIv",
table: "VaultItems",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "MasterKeyIv",
table: "Users",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.CreateIndex(
name: "IX_VaultItems_BlindIndex",
table: "VaultItems",
column: "BlindIndex");
}
}
}
@@ -0,0 +1,189 @@
// <auto-generated />
using System;
using Lockena.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lockena.Infrastructure.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lockena.Domain.Entities.EmailToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("NewEmail")
.HasColumnType("text");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("EmailTokens");
});
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("FingerPrint")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("ReplacedBy")
.HasColumnType("text");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("EncryptedMasterKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Salt")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TelegramId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedItemKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EncryptedPayload")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("VaultItems");
});
modelBuilder.Entity("Lockena.Domain.Entities.RefreshToken", b =>
{
b.HasOne("Lockena.Domain.Entities.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Lockena.Domain.Entities.VaultItem", b =>
{
b.HasOne("Lockena.Domain.Entities.User", null)
.WithMany("VaultItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lockena.Domain.Entities.User", b =>
{
b.Navigation("RefreshTokens");
b.Navigation("VaultItems");
});
#pragma warning restore 612, 618
}
}
}
+102
View File
@@ -0,0 +1,102 @@
# Lockena Backend
ASP.NET Core 10 API для менеджера паролей **Lockena**
---
## Назначение
Backend отвечает за:
- Аутентификацию пользователей
- Выдачу JWT и Refresh токенов
- Хранение зашифрованных записей
- Персистентность зашифрованного пользовательского ключа
Backend не выполняет криптографических операций над пользовательскими паролями.
---
## Архитектура
Слоистая архитектура:
```
Controllers
Application Services
Domain Models
Infrastructure (EF Core)
```
---
## Структура проекта
- `Lockena.API` — слой представления (авторизация, контроллеры, маршрутизация)
- `Lockena.Application` — бизнес‑логика, DTO
- `Lockena.Domain` — доменные сущности и базовые абстракции
- `Lockena.Infrastructure` — реализация репозиториев, EF Core, миграции, работа с БД
---
## Стек
- .NET 10 / ASP.NET Core Web API
- PostgreSQL
- Entity Framework Core для доступа к данным
- JWT‑аутентификация + refresh‑токены в HttpOnly cookie
- Docker + dockercompose для локального и продакшн‑запуска
---
## Безопасность
### Токены
- JWT (15 минут)
- Refresh token (7 дней)
### Хранение данных
Backend хранит:
- Зашифрованные записи
- Зашифрованный пользовательский ключ
Backend не хранит:
- Производные ключи
- Расшифрованные данные
---
## Конфигурация
| Переменная | Назначение |
| -------------------------------------- | -------------------------------------------------------------------------------------- |
| JwtSettings\_\_SecretKey | Секрет для подписи JWT |
| ConnectionStrings\_\_DefaultConnection | Строка подключения к PostgreSQL |
| AllowedCorsOrigins | Разрешенные CORS домены через ; (например, `https://localhost:5173`) |
| TelegramBotToken | Токен Telegram бота для мини-приложения |
| LogoDevApiKey | API ключ от сервиса [Logo.dev](https://logo.dev) для логотипов (можно оставить пустым) |
---
## Запуск
### Без Docker
```bash
dotnet restore
dotnet ef database update
dotnet run
```
### Через Docker
```bash
docker-compose up backend
```
+169
View File
@@ -0,0 +1,169 @@
# Lockena
Lockena — персональный zero-knowledge менеджер паролей.
Проект реализован как полнофункциональный full-stack монорепозиторий и демонстрирует:
- Клиентское шифрование (AES-GCM)
- Derivation ключей через Argon2id
- Zero-knowledge архитектуру
- ASP.NET Core backend
- JWT + Refresh Token модель
- Telegram Mini-App интеграцию
- Docker-деплой всего стека
---
## Почему этот проект существует
Большинство менеджеров паролей:
- Требуют доверия к серверу
- Закрыты архитектурно
- Имеют дорогую подписку
Lockena реализует модель, при которой:
- Сервер не имеет доступа к мастер-паролю
- Сервер не может расшифровать пользовательские данные
- Вся криптография происходит на клиенте
Цель проекта — практическая реализация zero-knowledge архитектуры.
---
## Архитектура
```mermaid
flowchart LR
User --> WebFrontend
User --> TelegramMiniApp
WebFrontend -->|Encrypted Payload| Backend
TelegramMiniApp -->|Encrypted Payload| Backend
Backend --> PostgreSQL
subgraph Client Side
WebFrontend
TelegramMiniApp
end
subgraph Server Side
Backend
PostgreSQL
end
```
### Архитектурный подход
- Клиентское шифрование перед отправкой на сервер
- Backend хранит только зашифрованные данные
- JWT-аутентификация (stateless)
- Refresh-токены с ограниченным сроком жизни
- Монорепо с изолированными приложениями
- Backend не участвует в дешифровании пользовательских данных.
---
## Структура монорепозитория
```
Lockena/
├── Lockena.Backend/ # ASP.NET Core 10 API
├── lockena-frontend/ # Web клиент (React + Vite)
├── lockena-miniapp/ # Telegram Mini-App (React + Vite)
├── nginx/ # Reverse proxy конфигурация
├── docker-compose.yaml # Оркестрация всего стека
└── README.md
```
Каждое приложение может собираться независимо.
---
## Технологии
### [Backend](./Lockena.Backend/README.md)
- ASP.NET Core 10
- Entity Framework Core
- PostgreSQL
- JWT + Refresh Tokens
### [Frontend](./lockena-frontend/README.md)
- React 19
- TypeScript
- Vite
- Zustand
- React Router
- TailwindCSS
- Axios
- libsodium-wrappers-sumo
### [Mini-App](./lockena-miniapp/README.md)
- React
- TypeScript
- Telegram WebApp API
### Infrastructure
- Docker
- Docker Compose
- NGINX
---
## Безопасность
### Модель шифрования
- AES-GCM для симметричного шифрования записей
- Шифрование выполняется на клиенте
- Сервер получает только encrypted payload
### Управление ключами
- Argon2id используется для derivation ключа
- На backend хранится только зашифрованный пользовательский ключ
- Дешифрование происходит исключительно на клиенте
### Аутентификация
- JWT access token — 15 минут
- Refresh token — 7 дней
- Stateless API
- TOTP пока не реализован
### Zero-Knowledge
Backend:
- Не хранит мастер-пароль
- Не может расшифровать пароли
- Не имеет доступа к ключам пользователя
---
## Запуск проекта
### Клонирование
```
git clone https://github.com/kindast/Lockena.git
cd Lockena
```
Переменные окружения для backend можно задать в `docker-compose.yaml`
| Переменная | Назначение |
| ------------------------------------ | ------------------------------- |
| JwtSettings__SecretKey | Секрет для подписи JWT |
| ConnectionStrings__DefaultConnection | Строка подключения к PostgreSQL |
| AllowedCorsOrigins | Разрешенные CORS домены через ; (например, `https://localhost:5173`) |
| TelegramBotToken | Токен Telegram бота для мини-приложения |
| LogoDevApiKey | API ключ от сервиса [Logo.dev](https://logo.dev) для логотипов (можно оставить пустым) |
### Запуск через Docker Compose
```
docker-compose up --build
```
Поднимаются:
- Backend
- PostgreSQL
- Frontend
- Mini-App
- NGINX
+60
View File
@@ -0,0 +1,60 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: lockena
POSTGRES_USER: postgres
POSTGRES_PASSWORD: VeryStrongPassword123!
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
backend:
build:
context: ./Lockena.Backend
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Port=5432;Database=lockena;Username=postgres;Password=VeryStrongPassword123!
AllowedCorsOrigins: "https://localhost:5173"
TelegramBotToken: "BotToken" #Токен из @BotFather для работы Telegram Mini App
LogoDevApiKey: "ApiKey" #API ключ от сервиса logo.dev для загрузки логотипов
ResendApiKey: "Api Key From resend.dev" #API ключ от сервиса resend.dev для отправки почты
LinkForEmailConfirmation: "https://localhost:5173/email-confirmation/" #Ссылка для подтверждения почты
JwtSettings__SecretKey: "VeryVeryVeryStrongJWTKey12345678" #JWT ключ минимум 32 символа
ASPNETCORE_ENVIRONMENT: Production
depends_on:
- postgres
expose:
- "8080"
restart: unless-stopped
frontend:
build:
context: ./lockena-frontend
restart: unless-stopped
expose:
- "80"
miniapp:
build:
context: ./lockena-miniapp
restart: unless-stopped
expose:
- "80"
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- backend
- frontend
- miniapp
restart: unless-stopped
volumes:
pgdata:
+334
View File
@@ -0,0 +1,334 @@
{
"name": "lockena-core",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lockena-core",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"axios": "^1.14.0",
"libsodium-wrappers-sumo": "^0.8.2"
},
"devDependencies": {
"@types/libsodium-wrappers-sumo": "^0.7.8"
}
},
"node_modules/@types/libsodium-wrappers": {
"version": "0.7.14",
"resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.14.tgz",
"integrity": "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/libsodium-wrappers-sumo": {
"version": "0.7.8",
"resolved": "https://registry.npmjs.org/@types/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.8.tgz",
"integrity": "sha512-N2+df4MB/A+W0RAcTw7A5oxKgzD+Vh6Ye7lfjWIi5SdTzVLfHPzxUjhwPqHLO5Ev9fv/+VHl+sUaUuTg4fUPqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/libsodium-wrappers": "*"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/libsodium-sumo": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz",
"integrity": "sha512-uMgnjphJ717jLN+jFG1HUgNrK/gOVVfaO1DGZ1Ig/fKLKLVhvaH/sM1I1v784JFvmkJDaczDpi7xSYC4Jvdo1Q==",
"license": "ISC"
},
"node_modules/libsodium-wrappers-sumo": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.2.tgz",
"integrity": "sha512-wd1xAY++Kr6VMikSaa4EPRAHJmFvNlGWiiwU3Jh3GR1zRYF3/I3vy/wYsr4k3LVsNzwb9sqfEQ4LdVQ6zEebyQ==",
"license": "ISC",
"dependencies": {
"libsodium-sumo": "^0.8.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "lockena-core",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"axios": "^1.14.0",
"libsodium-wrappers-sumo": "^0.8.2"
},
"devDependencies": {
"@types/libsodium-wrappers-sumo": "^0.7.8"
}
}
+90
View File
@@ -0,0 +1,90 @@
import axios, {
type AxiosError,
type AxiosResponse,
type AxiosRequestConfig,
type InternalAxiosRequestConfig,
} from "axios";
import type { ApiConfig } from "./types";
import { BACKEND_BASE_URL } from "../constants";
export const httpClient = axios.create({
baseURL: BACKEND_BASE_URL,
withCredentials: true,
});
// Единая точка конфигурации клиента извне
let apiConfig: ApiConfig | null = null;
export const setupHttpClient = (config: ApiConfig) => {
apiConfig = config;
};
//Прикрепляем токен к запросам
httpClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = apiConfig?.getToken() ?? null;
if (token) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
//Повтор запроса при 401
let isRefreshing = false;
let failedQueue: Array<{ resolve: () => void; reject: (err: any) => void }> =
[];
const processQueue = (error: AxiosError | null) => {
failedQueue.forEach((prom) => {
if (error) prom.reject(error);
else prom.resolve();
});
failedQueue = [];
};
httpClient.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError) => {
const originalRequest = error.config as AxiosRequestConfig & {
_retry?: boolean;
};
if (
error.response?.status === 401 &&
!originalRequest._retry &&
originalRequest.url !== "/auth/refresh" &&
originalRequest.url !== "/auth/logout"
) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: () => resolve(httpClient(originalRequest)),
reject,
});
});
}
originalRequest._retry = true;
isRefreshing = true;
if (!apiConfig) {
return Promise.reject(error);
}
try {
const success = await apiConfig.refreshToken();
if (success) {
processQueue(null);
return httpClient(originalRequest);
}
} catch (refreshError) {
processQueue(refreshError as AxiosError);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);
+15
View File
@@ -0,0 +1,15 @@
import { setupHttpClient } from "./httpClient";
import { authService } from "./services/authService";
import { userService } from "./services/userService";
import { logoService } from "./services/logoService";
import { vaultService } from "./services/vaultService";
import type { ApiConfig } from "./types";
export {
setupHttpClient,
type ApiConfig,
authService,
userService,
logoService,
vaultService,
};
+82
View File
@@ -0,0 +1,82 @@
import { keyService } from "../../crypto/services/keyService";
import type { AuthDto, SignUpDto } from "../../dto/auth";
import type { MessageDto, RequestResult } from "../../dto/response";
import { httpClient } from "../httpClient";
import { executeRequest } from "../utils/executeRequest";
export const authService = {
async signUp(
email: string,
password: string,
): Promise<RequestResult<AuthDto>> {
const crypto = await keyService.create(password);
const data: SignUpDto = {
email,
password,
encryptedMasterKey: crypto.protectedMasterKey.encryptedMasterKey,
salt: crypto.protectedMasterKey.salt,
};
return executeRequest(httpClient.post<AuthDto>("/auth/signup", data));
},
async signIn(
email: string,
password: string,
): Promise<RequestResult<AuthDto>> {
return executeRequest(
httpClient.post<AuthDto>("/auth/signin", { email, password }),
);
},
async signUpWithTelegram(
initData: string,
password: string,
): Promise<RequestResult<AuthDto>> {
const crypto = await keyService.create(password);
const data = {
initData,
password,
encryptedMasterKey: crypto.protectedMasterKey.encryptedMasterKey,
salt: crypto.protectedMasterKey.salt,
};
return executeRequest(
httpClient.post<AuthDto>("/auth/telegram-signup", data),
);
},
async signInWithTelegram(initData: string): Promise<RequestResult<AuthDto>> {
return executeRequest(
httpClient.post<AuthDto>("/auth/telegram-signin", { initData }),
);
},
async refresh(): Promise<RequestResult<AuthDto>> {
return executeRequest(httpClient.post<AuthDto>("/auth/refresh"));
},
async logout(): Promise<RequestResult<MessageDto>> {
return executeRequest(httpClient.post<MessageDto>("/auth/logout"));
},
async sendEmailConfirmation(
credentials: string,
): Promise<RequestResult<MessageDto>> {
return executeRequest(
httpClient.post<MessageDto>("/auth/send-email-confirmation", {
credentials,
}),
);
},
async verifyEmail(token: string): Promise<RequestResult<MessageDto>> {
return executeRequest(
httpClient.post<MessageDto>("/auth/confirm-email", { token }),
);
},
async linkEmail(email: string): Promise<RequestResult<MessageDto>> {
return executeRequest(
httpClient.post<MessageDto>("/auth/telegram-link-email", { email }),
);
},
};
+12
View File
@@ -0,0 +1,12 @@
import { httpClient } from "../httpClient";
import { executeRequest } from "../utils/executeRequest";
export const logoService = {
async get(service: string) {
return executeRequest(
httpClient.get<Blob>("/logo/" + service, {
responseType: "blob",
}),
);
},
};
+34
View File
@@ -0,0 +1,34 @@
import { keyService } from "../../crypto/services/keyService";
import type { MessageDto, RequestResult } from "../../dto/response";
import type { ChangePasswordDto, ProfileDto } from "../../dto/user";
import { httpClient } from "../httpClient";
import { executeRequest } from "../utils/executeRequest";
export const userService = {
async getProfile(): Promise<RequestResult<ProfileDto>> {
return executeRequest(httpClient.get<ProfileDto>("/users"));
},
async deleteAccount(password: string): Promise<RequestResult<MessageDto>> {
return executeRequest(
httpClient.delete<MessageDto>("/users", { data: { password } }),
);
},
async changePassword(
currentPassword: string,
newPassword: string,
masterKey: Uint8Array,
): Promise<RequestResult<MessageDto>> {
const crypto = await keyService.create(newPassword, masterKey);
const payload: ChangePasswordDto = {
currentPassword,
newPassword,
encryptedMasterKey: crypto.protectedMasterKey.encryptedMasterKey,
salt: crypto.protectedMasterKey.salt,
};
return executeRequest(
httpClient.put<MessageDto>("/users/change-password", payload),
);
},
};
+35
View File
@@ -0,0 +1,35 @@
import type { MessageDto, RequestResult } from "../../dto/response";
import type {
CreateVaultItemDto,
GetVaultItemsDto,
VaultItemDto,
} from "../../dto/vault-item";
import { httpClient } from "../httpClient";
import { executeRequest } from "../utils/executeRequest";
export const vaultService = {
async getVaultItem(id: string): Promise<RequestResult<VaultItemDto>> {
return executeRequest(httpClient.get<VaultItemDto>(`/vault-items/${id}`));
},
async getVaultItems(): Promise<RequestResult<GetVaultItemsDto>> {
return executeRequest(httpClient.get<GetVaultItemsDto>("/vault-items"));
},
async createVaultItem(
encryptedVaultItem: CreateVaultItemDto,
): Promise<RequestResult<VaultItemDto>> {
return executeRequest(
httpClient.post<VaultItemDto>("/vault-items", encryptedVaultItem),
);
},
async updateVaultItem(
id: string,
encryptedVaultItem: CreateVaultItemDto,
): Promise<RequestResult<VaultItemDto>> {
return executeRequest(
httpClient.put<VaultItemDto>(`/vault-items/${id}`, encryptedVaultItem),
);
},
async deleteVaultItem(id: string): Promise<RequestResult<MessageDto>> {
return executeRequest(httpClient.delete<MessageDto>(`/vault-items/${id}`));
},
};
+4
View File
@@ -0,0 +1,4 @@
export interface ApiConfig {
getToken: () => string | null;
refreshToken: () => Promise<boolean>;
}
@@ -0,0 +1,19 @@
import { AxiosError } from "axios";
import type { ErrorDto } from "../../dto/response/error.dto";
import type { RequestResult } from "../../dto/response";
export async function executeRequest<T>(
requestPromise: Promise<{ status: number; data: T }>,
): Promise<RequestResult<T>> {
try {
const response = await requestPromise;
return { state: "success", code: response.status, data: response.data };
} catch (error) {
const serverError = error as AxiosError<ErrorDto>;
return {
state: "error",
code: serverError.response?.status || 500,
errors: serverError.response?.data.errors || [],
};
}
}
+6
View File
@@ -0,0 +1,6 @@
export const BACKEND_BASE_URL = "http://localhost:5000/api";
export const CIPHER_VERSION = "v1"; //Версия формата шифрования для миграции на новые форматы
export const AES_OPTIONS = {
ALGO: "AES-GCM",
IV_LENGTH: 12,
};
+5
View File
@@ -0,0 +1,5 @@
import { keyService } from "./services/keyService";
import { vaultCryptoService } from "./services/vaultCryptoService";
import { sodiumLoader } from "./utils/sodiumLoader";
export { keyService, vaultCryptoService, sodiumLoader };

Some files were not shown because too many files have changed in this diff Show More