diff --git a/FinancialTracker/Controllers/AuthController.cs b/FinancialTracker/Controllers/AuthController.cs new file mode 100644 index 0000000..64fdceb --- /dev/null +++ b/FinancialTracker/Controllers/AuthController.cs @@ -0,0 +1,71 @@ +using FinancialTracker.DTOs.AuthDtos; +using FinancialTracker.Interfaces.Auth; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; + +namespace FinancialTracker.Controllers; + +[ApiController] +[Route("api/auth")] +public class AuthController(IAuthService authService, ILogger logger) : ControllerBase +{ + [HttpPost("register")] + public async Task> Register(RegisterRequestDto registerDto) + { + try + { + var result = await authService.RegisterAsync(registerDto); + return Ok(result); + } + catch (InvalidOperationException ex) + { + logger.LogWarning("Ошибка регистрации: {Message}", ex.Message); + return BadRequest(new { message = ex.Message }); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при регистрации"); + return StatusCode(500, new { message = "Внутренняя ошибка сервера при регистрации" }); + } + } + + [HttpPost("login")] + public async Task> Login(LoginRequestDto loginDto) + { + try + { + var result = await authService.LoginAsync(loginDto); + return Ok(result); + } + catch (InvalidOperationException ex) + { + logger.LogWarning("Ошибка входа: {Message}", ex.Message); + return Unauthorized(new { message = ex.Message }); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при входе"); + return StatusCode(500, new { message = "Внутренняя ошибка сервера при входе" }); + } + } + + [HttpPost("refresh")] + public async Task> Refresh(RefreshTokenRequestDto refreshDto) + { + try + { + var result = await authService.RefreshTokenAsync(refreshDto.RefreshToken); + return Ok(result); + } + catch (SecurityTokenException ex) + { + logger.LogWarning("Невалидный refresh token: {Message}", ex.Message); + return Unauthorized(new { message = "Невалидный refresh token" }); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при обновлении токена"); + return StatusCode(500, new { message = "Внутренняя ошибка сервера при обновлении токена" }); + } + } +} \ No newline at end of file diff --git a/FinancialTracker/Controllers/CategoriesController.cs b/FinancialTracker/Controllers/CategoriesController.cs index 5a61595..0ecb497 100644 --- a/FinancialTracker/Controllers/CategoriesController.cs +++ b/FinancialTracker/Controllers/CategoriesController.cs @@ -1,9 +1,11 @@ using FinancialTracker.DTOs.CategoryDtos; using FinancialTracker.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace FinancialTracker.Controllers; +[Authorize] [ApiController] [Route("api/[controller]")] public class CategoriesController( diff --git a/FinancialTracker/Controllers/TransactionsController.cs b/FinancialTracker/Controllers/TransactionsController.cs index e0473b5..1928731 100644 --- a/FinancialTracker/Controllers/TransactionsController.cs +++ b/FinancialTracker/Controllers/TransactionsController.cs @@ -1,9 +1,11 @@ using FinancialTracker.DTOs.TransactionDtos; using FinancialTracker.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace FinancialTracker.Controllers; +[Authorize] [ApiController] [Route("api/[controller]")] public class TransactionsController( diff --git a/FinancialTracker/DTOs/AuthDtos/AuthResponseDto.cs b/FinancialTracker/DTOs/AuthDtos/AuthResponseDto.cs new file mode 100644 index 0000000..7434654 --- /dev/null +++ b/FinancialTracker/DTOs/AuthDtos/AuthResponseDto.cs @@ -0,0 +1,9 @@ +namespace FinancialTracker.DTOs.AuthDtos; + +public class AuthResponseDto +{ + public required string AccessToken { get; set; } + public required string RefreshToken { get; set; } + public DateTime AccessTokenExpires { get; set; } + public DateTime RefreshTokenExpires { get; set; } +} \ No newline at end of file diff --git a/FinancialTracker/DTOs/AuthDtos/LoginRequestDto.cs b/FinancialTracker/DTOs/AuthDtos/LoginRequestDto.cs new file mode 100644 index 0000000..e0dd52d --- /dev/null +++ b/FinancialTracker/DTOs/AuthDtos/LoginRequestDto.cs @@ -0,0 +1,7 @@ +namespace FinancialTracker.DTOs.AuthDtos; + +public class LoginRequestDto +{ + public required string Email { get; set; } + public required string Password { get; set; } +} \ No newline at end of file diff --git a/FinancialTracker/DTOs/AuthDtos/RefreshTokenRequestDto.cs b/FinancialTracker/DTOs/AuthDtos/RefreshTokenRequestDto.cs new file mode 100644 index 0000000..775f6ea --- /dev/null +++ b/FinancialTracker/DTOs/AuthDtos/RefreshTokenRequestDto.cs @@ -0,0 +1,6 @@ +namespace FinancialTracker.DTOs.AuthDtos; + +public class RefreshTokenRequestDto +{ + public required string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/FinancialTracker/DTOs/AuthDtos/RegisterRequestDto.cs b/FinancialTracker/DTOs/AuthDtos/RegisterRequestDto.cs new file mode 100644 index 0000000..8249e9b --- /dev/null +++ b/FinancialTracker/DTOs/AuthDtos/RegisterRequestDto.cs @@ -0,0 +1,9 @@ +namespace FinancialTracker.DTOs.AuthDtos; + +public class RegisterRequestDto +{ + public required string Email { get; set; } + public required string Password { get; set; } + public required string FirstName { get; set; } + public required string LastName { get; set; } +} \ No newline at end of file diff --git a/FinancialTracker/Data/Entities/Category.cs b/FinancialTracker/Data/Entities/Category.cs index 8f8658a..c005100 100644 --- a/FinancialTracker/Data/Entities/Category.cs +++ b/FinancialTracker/Data/Entities/Category.cs @@ -4,6 +4,8 @@ public class Category { public int Id { get; set; } public required string Name { get; set; } + public Guid UserId { get; set; } public List Transactions { get; set; } = new(); + public User User { get; set; } = null!; } \ No newline at end of file diff --git a/FinancialTracker/Data/Entities/RefreshToken.cs b/FinancialTracker/Data/Entities/RefreshToken.cs new file mode 100644 index 0000000..f6e79cb --- /dev/null +++ b/FinancialTracker/Data/Entities/RefreshToken.cs @@ -0,0 +1,13 @@ +namespace FinancialTracker.Data.Entities; + +public class RefreshToken +{ + public int Id { get; set; } + public required string Token { get; set; } + public Guid UserId { get; set; } + public DateTime Expires { get; set; } + public DateTime Created { get; set; } = DateTime.UtcNow; + public bool IsRevoked { get; set; } + + public User User { get; set; } = null!; +} \ No newline at end of file diff --git a/FinancialTracker/Data/Entities/Transaction.cs b/FinancialTracker/Data/Entities/Transaction.cs index 69615ea..6213daf 100644 --- a/FinancialTracker/Data/Entities/Transaction.cs +++ b/FinancialTracker/Data/Entities/Transaction.cs @@ -13,4 +13,7 @@ public class Transaction public required int CategoryId { get; set; } public Category Category { get; set; } = null!; + + public Guid UserId { get; set; } + public User User { get; set; } = null!; } \ No newline at end of file diff --git a/FinancialTracker/Data/Entities/User.cs b/FinancialTracker/Data/Entities/User.cs new file mode 100644 index 0000000..215f36d --- /dev/null +++ b/FinancialTracker/Data/Entities/User.cs @@ -0,0 +1,14 @@ +namespace FinancialTracker.Data.Entities; + +public class User +{ + public Guid Id { get; set; } + public required string Email { get; set; } + public required string PasswordHash { get; set; } + public required string FirstName { get; set; } + public required string LastName { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public List Transactions { get; set; } = new(); + public List Categories { get; set; } = new(); +} \ No newline at end of file diff --git a/FinancialTracker/Data/FinancialDbContext.cs b/FinancialTracker/Data/FinancialDbContext.cs index 7ce4571..95a3814 100644 --- a/FinancialTracker/Data/FinancialDbContext.cs +++ b/FinancialTracker/Data/FinancialDbContext.cs @@ -8,11 +8,15 @@ public class FinancialDbContext(DbContextOptions options) : { public DbSet Categories { get; set; } public DbSet Transactions { get; set; } + public DbSet Users { get; set; } + public DbSet RefreshTokens { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.ApplyConfiguration(new UserConfiguration()); modelBuilder.ApplyConfiguration(new CategoryConfiguration()); modelBuilder.ApplyConfiguration(new TransactionConfiguration()); + modelBuilder.ApplyConfiguration(new RefreshTokenConfiguration()); base.OnModelCreating(modelBuilder); } } @@ -23,21 +27,18 @@ public void Configure(EntityTypeBuilder builder) { builder.HasKey(e => e.Id); - builder.HasIndex(e => e.Name) + builder.HasIndex(e => new { e.Name, e.UserId }) .IsUnique(); builder.HasMany(e => e.Transactions) .WithOne(e => e.Category) .HasForeignKey(e => e.CategoryId) - .OnDelete(DeleteBehavior.Restrict); - - builder.HasData( - new Category { Id = 1, Name = "Еда" }, - new Category { Id = 2, Name = "Транспорт" }, - new Category { Id = 3, Name = "Развлечения" }, - new Category { Id = 4, Name = "Зарплата" }, - new Category { Id = 5, Name = "Прочее" } - ); + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.User) + .WithMany(e => e.Categories) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); } } @@ -55,5 +56,70 @@ public void Configure(EntityTypeBuilder builder) .WithMany(e => e.Transactions) .HasForeignKey(e => e.CategoryId) .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.User) + .WithMany(e => e.Transactions) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + + builder.HasIndex(e => e.Email) + .IsUnique(); + + builder.Property(e => e.Email) + .IsRequired(); + + builder.Property(e => e.PasswordHash) + .IsRequired(); + + builder.Property(e => e.FirstName) + .IsRequired() + .HasMaxLength(100); + + builder.Property(e => e.LastName) + .IsRequired() + .HasMaxLength(100); + + builder.HasMany(e => e.Transactions) + .WithOne(e => e.User) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(e => e.Categories) + .WithOne(e => e.User) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + + builder.HasIndex(e => e.Token) + .IsUnique(); + + builder.Property(e => e.Token) + .IsRequired() + .HasMaxLength(500); + + builder.Property(e => e.Expires) + .IsRequired(); + + builder.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(e => new { e.UserId, e.IsRevoked, e.Expires }); } } \ No newline at end of file diff --git a/FinancialTracker/Data/Migrations/20250922112610_InitialCreate.Designer.cs b/FinancialTracker/Data/Migrations/20250922112610_InitialCreate.Designer.cs new file mode 100644 index 0000000..6f90546 --- /dev/null +++ b/FinancialTracker/Data/Migrations/20250922112610_InitialCreate.Designer.cs @@ -0,0 +1,166 @@ +// +using System; +using FinancialTracker.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 FinancialTracker.Data.Migrations +{ + [DbContext(typeof(FinancialDbContext))] + [Migration("20250922112610_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinancialTracker.Data.Entities.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Name", "UserId") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("UserId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.Category", b => + { + b.HasOne("FinancialTracker.Data.Entities.User", "User") + .WithMany("Categories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.Transaction", b => + { + b.HasOne("FinancialTracker.Data.Entities.Category", "Category") + .WithMany("Transactions") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FinancialTracker.Data.Entities.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.Category", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.User", b => + { + b.Navigation("Categories"); + + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinancialTracker/Data/Migrations/20250922112610_InitialCreate.cs b/FinancialTracker/Data/Migrations/20250922112610_InitialCreate.cs new file mode 100644 index 0000000..800f33e --- /dev/null +++ b/FinancialTracker/Data/Migrations/20250922112610_InitialCreate.cs @@ -0,0 +1,167 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace FinancialTracker.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Categories_Name", + table: "Categories"); + + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "Id", + keyValue: 1); + + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "Id", + keyValue: 2); + + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "Id", + keyValue: 3); + + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "Id", + keyValue: 4); + + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "Id", + keyValue: 5); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Transactions", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Categories", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Email = table.Column(type: "text", nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + FirstName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + LastName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_UserId", + table: "Transactions", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Categories_Name_UserId", + table: "Categories", + columns: new[] { "Name", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Categories_UserId", + table: "Categories", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Categories_Users_UserId", + table: "Categories", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Transactions_Users_UserId", + table: "Transactions", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Categories_Users_UserId", + table: "Categories"); + + migrationBuilder.DropForeignKey( + name: "FK_Transactions_Users_UserId", + table: "Transactions"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_UserId", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Categories_Name_UserId", + table: "Categories"); + + migrationBuilder.DropIndex( + name: "IX_Categories_UserId", + table: "Categories"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Transactions"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Categories"); + + migrationBuilder.InsertData( + table: "Categories", + columns: new[] { "Id", "Name" }, + values: new object[,] + { + { 1, "Еда" }, + { 2, "Транспорт" }, + { 3, "Развлечения" }, + { 4, "Зарплата" }, + { 5, "Прочее" } + }); + + migrationBuilder.CreateIndex( + name: "IX_Categories_Name", + table: "Categories", + column: "Name", + unique: true); + } + } +} diff --git a/FinancialTracker/Data/Migrations/20250923083427_AddRefreshTokens.Designer.cs b/FinancialTracker/Data/Migrations/20250923083427_AddRefreshTokens.Designer.cs new file mode 100644 index 0000000..0196f21 --- /dev/null +++ b/FinancialTracker/Data/Migrations/20250923083427_AddRefreshTokens.Designer.cs @@ -0,0 +1,212 @@ +// +using System; +using FinancialTracker.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 FinancialTracker.Data.Migrations +{ + [DbContext(typeof(FinancialDbContext))] + [Migration("20250923083427_AddRefreshTokens")] + partial class AddRefreshTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinancialTracker.Data.Entities.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Name", "UserId") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked", "Expires"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("UserId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.Category", b => + { + b.HasOne("FinancialTracker.Data.Entities.User", "User") + .WithMany("Categories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.RefreshToken", b => + { + b.HasOne("FinancialTracker.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.Transaction", b => + { + b.HasOne("FinancialTracker.Data.Entities.Category", "Category") + .WithMany("Transactions") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FinancialTracker.Data.Entities.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.Category", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.User", b => + { + b.Navigation("Categories"); + + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinancialTracker/Data/Migrations/20250923083427_AddRefreshTokens.cs b/FinancialTracker/Data/Migrations/20250923083427_AddRefreshTokens.cs new file mode 100644 index 0000000..a57478d --- /dev/null +++ b/FinancialTracker/Data/Migrations/20250923083427_AddRefreshTokens.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinancialTracker.Data.Migrations +{ + /// + public partial class AddRefreshTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Token = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Expires = table.Column(type: "timestamp with time zone", nullable: false), + Created = table.Column(type: "timestamp with time zone", nullable: false), + IsRevoked = table.Column(type: "boolean", nullable: false) + }, + 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.CreateIndex( + name: "IX_RefreshTokens_Token", + table: "RefreshTokens", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId_IsRevoked_Expires", + table: "RefreshTokens", + columns: new[] { "UserId", "IsRevoked", "Expires" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens"); + } + } +} diff --git a/FinancialTracker/Data/Migrations/FinancialDbContextModelSnapshot.cs b/FinancialTracker/Data/Migrations/FinancialDbContextModelSnapshot.cs index 770fc5c..ec840dc 100644 --- a/FinancialTracker/Data/Migrations/FinancialDbContextModelSnapshot.cs +++ b/FinancialTracker/Data/Migrations/FinancialDbContextModelSnapshot.cs @@ -34,39 +34,52 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("uuid"); + b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("UserId"); + + b.HasIndex("Name", "UserId") .IsUnique(); b.ToTable("Categories"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserId") + .HasColumnType("uuid"); - b.HasData( - new - { - Id = 1, - Name = "Еда" - }, - new - { - Id = 2, - Name = "Транспорт" - }, - new - { - Id = 3, - Name = "Развлечения" - }, - new - { - Id = 4, - Name = "Зарплата" - }, - new - { - Id = 5, - Name = "Прочее" - }); + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked", "Expires"); + + b.ToTable("RefreshTokens"); }); modelBuilder.Entity("FinancialTracker.Data.Entities.Transaction", b => @@ -91,13 +104,75 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("uuid"); + b.HasKey("Id"); b.HasIndex("CategoryId"); + b.HasIndex("UserId"); + b.ToTable("Transactions"); }); + modelBuilder.Entity("FinancialTracker.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.Category", b => + { + b.HasOne("FinancialTracker.Data.Entities.User", "User") + .WithMany("Categories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.RefreshToken", b => + { + b.HasOne("FinancialTracker.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("FinancialTracker.Data.Entities.Transaction", b => { b.HasOne("FinancialTracker.Data.Entities.Category", "Category") @@ -106,13 +181,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("FinancialTracker.Data.Entities.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.Navigation("Category"); + + b.Navigation("User"); }); modelBuilder.Entity("FinancialTracker.Data.Entities.Category", b => { b.Navigation("Transactions"); }); + + modelBuilder.Entity("FinancialTracker.Data.Entities.User", b => + { + b.Navigation("Categories"); + + b.Navigation("Transactions"); + }); #pragma warning restore 612, 618 } } diff --git a/FinancialTracker/FinancialTracker.csproj b/FinancialTracker/FinancialTracker.csproj index ca3bed5..143fdd0 100644 --- a/FinancialTracker/FinancialTracker.csproj +++ b/FinancialTracker/FinancialTracker.csproj @@ -7,11 +7,14 @@ + + + diff --git a/FinancialTracker/Interfaces/Auth/IAuthService.cs b/FinancialTracker/Interfaces/Auth/IAuthService.cs new file mode 100644 index 0000000..cca0a01 --- /dev/null +++ b/FinancialTracker/Interfaces/Auth/IAuthService.cs @@ -0,0 +1,11 @@ +using FinancialTracker.DTOs.AuthDtos; + +namespace FinancialTracker.Interfaces.Auth; + +public interface IAuthService +{ + Task RegisterAsync(RegisterRequestDto registerDto); + Task LoginAsync(LoginRequestDto loginDto); + Task RefreshTokenAsync(string refreshToken); + Task RevokeRefreshTokenAsync(string refreshToken); +} \ No newline at end of file diff --git a/FinancialTracker/Interfaces/Auth/IPasswordHasher.cs b/FinancialTracker/Interfaces/Auth/IPasswordHasher.cs new file mode 100644 index 0000000..19de4fa --- /dev/null +++ b/FinancialTracker/Interfaces/Auth/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace FinancialTracker.Interfaces.Auth; + +public interface IPasswordHasher +{ + string HashPassword(string password); + bool VerifyPassword(string password, string hashedPassword); +} \ No newline at end of file diff --git a/FinancialTracker/Interfaces/JWT/IJwtService.cs b/FinancialTracker/Interfaces/JWT/IJwtService.cs new file mode 100644 index 0000000..50fde9c --- /dev/null +++ b/FinancialTracker/Interfaces/JWT/IJwtService.cs @@ -0,0 +1,11 @@ +using System.Security.Claims; +using FinancialTracker.Data.Entities; + +namespace FinancialTracker.Interfaces.JWT; + +public interface IJwtService +{ + string GenerateAccessToken(User user); + string GenerateRefreshToken(); + ClaimsPrincipal? GetPrincipalFromExpiredToken(string token); +} \ No newline at end of file diff --git a/FinancialTracker/Program.cs b/FinancialTracker/Program.cs index d5687b6..19df39c 100644 --- a/FinancialTracker/Program.cs +++ b/FinancialTracker/Program.cs @@ -1,8 +1,14 @@ +using System.Text; using FinancialTracker.Data; using FinancialTracker.Data.Repositories; using FinancialTracker.Interfaces; +using FinancialTracker.Interfaces.Auth; +using FinancialTracker.Interfaces.JWT; using FinancialTracker.Services; +using FinancialTracker.Settings; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); @@ -13,6 +19,29 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.Configure(builder.Configuration.GetSection("JwtSettings")); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["JwtSettings:Issuer"], + ValidAudience = builder.Configuration["JwtSettings:Audience"], + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.ASCII.GetBytes(builder.Configuration["JwtSettings:Secret"]!)) + }; + }); + +builder.Services.AddAuthorization(); + builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); @@ -48,4 +77,7 @@ app.UseRouting(); app.MapControllers(); +app.UseAuthentication(); +app.UseAuthorization(); + app.Run(); diff --git a/FinancialTracker/Services/AuthService.cs b/FinancialTracker/Services/AuthService.cs new file mode 100644 index 0000000..02b24bc --- /dev/null +++ b/FinancialTracker/Services/AuthService.cs @@ -0,0 +1,144 @@ +using System.Security.Claims; +using FinancialTracker.Data; +using FinancialTracker.Data.Entities; +using FinancialTracker.DTOs.AuthDtos; +using FinancialTracker.Interfaces.Auth; +using FinancialTracker.Interfaces.JWT; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; + +namespace FinancialTracker.Services; + +public class AuthService(FinancialDbContext context, IJwtService jwtService, IPasswordHasher passwordHasher) + : IAuthService +{ + public async Task RegisterAsync(RegisterRequestDto registerDto) + { + if (await context.Users.AnyAsync(u => u.Email == registerDto.Email)) + throw new InvalidOperationException("Пользователь с таким email уже существует"); + + var user = new User + { + Id = Guid.NewGuid(), + Email = registerDto.Email, + PasswordHash = passwordHasher.HashPassword(registerDto.Password), + FirstName = registerDto.FirstName, + LastName = registerDto.LastName + }; + + context.Users.Add(user); + await context.SaveChangesAsync(); + + await CreateDefaultCategoriesForUser(user.Id); + + return await GenerateTokens(user); + } + + public async Task LoginAsync(LoginRequestDto loginDto) + { + var user = await context.Users.FirstOrDefaultAsync(u => u.Email == loginDto.Email); + if (user == null || !passwordHasher.VerifyPassword(loginDto.Password, user.PasswordHash)) + throw new InvalidOperationException("Неверный email или пароль"); + + await RevokeUserRefreshTokens(user.Id); + + return await GenerateTokens(user); + } + + public async Task RefreshTokenAsync(string refreshToken) + { + var storedToken = await context.RefreshTokens + .Include(rt => rt.User) + .FirstOrDefaultAsync(rt => rt.Token == refreshToken); + + if (storedToken == null) + throw new SecurityTokenException("Refresh token не найден"); + + if (storedToken.IsRevoked) + throw new SecurityTokenException("Refresh token отозван"); + + if (storedToken.Expires < DateTime.UtcNow) + throw new SecurityTokenException("Refresh token истек"); + + try + { + jwtService.GetPrincipalFromExpiredToken(refreshToken); + } + catch + { + storedToken.IsRevoked = true; + await context.SaveChangesAsync(); + throw new SecurityTokenException("Неверный refresh token"); + } + + storedToken.IsRevoked = true; + + return await GenerateTokens(storedToken.User); + } + + public async Task RevokeRefreshTokenAsync(string refreshToken) + { + var storedToken = await context.RefreshTokens + .FirstOrDefaultAsync(rt => rt.Token == refreshToken); + + if (storedToken != null && !storedToken.IsRevoked) + { + storedToken.IsRevoked = true; + await context.SaveChangesAsync(); + } + } + + public async Task RevokeUserRefreshTokens(Guid userId) + { + var activeTokens = await context.RefreshTokens + .Where(rt => rt.UserId == userId && !rt.IsRevoked && rt.Expires > DateTime.UtcNow) + .ToListAsync(); + + foreach (var token in activeTokens) + { + token.IsRevoked = true; + } + + await context.SaveChangesAsync(); + } + + private async Task GenerateTokens(User user) + { + var accessToken = jwtService.GenerateAccessToken(user); + var refreshToken = jwtService.GenerateRefreshToken(); + var refreshTokenEntity = new RefreshToken + { + Token = refreshToken, + UserId = user.Id, + Expires = DateTime.UtcNow.AddDays(30), + Created = DateTime.UtcNow, + IsRevoked = false + }; + + context.RefreshTokens.Add(refreshTokenEntity); + await context.SaveChangesAsync(); + + return new AuthResponseDto + { + AccessToken = accessToken, + RefreshToken = refreshToken, + AccessTokenExpires = DateTime.UtcNow.AddMinutes(5), + RefreshTokenExpires = DateTime.UtcNow.AddDays(30), + }; + } + + private async Task CreateDefaultCategoriesForUser(Guid userId) + { + var defaultCategories = new[] + { + new Category { Name = "Еда", UserId = userId }, + new Category { Name = "Транспорт", UserId = userId }, + new Category { Name = "Развлечения", UserId = userId }, + new Category { Name = "Зарплата", UserId = userId }, + new Category { Name = "Прочее", UserId = userId } + }; + + await context.Categories.AddRangeAsync(defaultCategories); + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/FinancialTracker/Services/JwtService.cs b/FinancialTracker/Services/JwtService.cs new file mode 100644 index 0000000..b42cc37 --- /dev/null +++ b/FinancialTracker/Services/JwtService.cs @@ -0,0 +1,72 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using FinancialTracker.Data.Entities; +using FinancialTracker.Interfaces.JWT; +using FinancialTracker.Settings; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace FinancialTracker.Services; + +public class JwtService(IOptions jwtSettings) : IJwtService +{ + private readonly JwtSettings _jwtSettings = jwtSettings.Value; + + public string GenerateAccessToken(User user) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.ASCII.GetBytes(_jwtSettings.Secret); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.GivenName, user.FirstName), + new Claim(ClaimTypes.Surname, user.LastName) + }; + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpirationMinutes), + Issuer = _jwtSettings.Issuer, + Audience = _jwtSettings.Audience, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + public string GenerateRefreshToken() + { + var randomNumber = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomNumber); + return Convert.ToBase64String(randomNumber); + } + + public ClaimsPrincipal? GetPrincipalFromExpiredToken(string token) + { + var tokenValidationParameters = new TokenValidationParameters + { + ValidateAudience = false, + ValidateIssuer = false, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_jwtSettings.Secret)), + ValidateLifetime = false + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken); + + if (securityToken is not JwtSecurityToken jwtSecurityToken || + !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase)) + throw new SecurityTokenException("Токен не валидный"); + + return principal; + } +} \ No newline at end of file diff --git a/FinancialTracker/Settings/JwtSettings.cs b/FinancialTracker/Settings/JwtSettings.cs new file mode 100644 index 0000000..23ba2cb --- /dev/null +++ b/FinancialTracker/Settings/JwtSettings.cs @@ -0,0 +1,10 @@ +namespace FinancialTracker.Settings; + +public class JwtSettings +{ + public string Secret { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public int AccessTokenExpirationMinutes { get; set; } = 5; + public int RefreshTokenExpirationDays { get; set; } = 30; +} \ No newline at end of file diff --git a/FinancialTracker/Settings/PasswordHasher.cs b/FinancialTracker/Settings/PasswordHasher.cs new file mode 100644 index 0000000..659363c --- /dev/null +++ b/FinancialTracker/Settings/PasswordHasher.cs @@ -0,0 +1,42 @@ +using System.Security.Cryptography; +using FinancialTracker.Interfaces.Auth; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; + +namespace FinancialTracker.Settings; + +public class PasswordHasher : IPasswordHasher +{ + public string HashPassword(string password) + { + var salt = new byte[16]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(salt); + + var hashed = KeyDerivation.Pbkdf2( + password: password, + salt: salt, + prf: KeyDerivationPrf.HMACSHA256, + iterationCount: 10000, + numBytesRequested: 32); + + return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hashed)}"; + } + + public bool VerifyPassword(string password, string hashedPassword) + { + var parts = hashedPassword.Split('.'); + if (parts.Length != 2) return false; + + var salt = Convert.FromBase64String(parts[0]); + var storedHash = Convert.FromBase64String(parts[1]); + + var computedHash = KeyDerivation.Pbkdf2( + password: password, + salt: salt, + prf: KeyDerivationPrf.HMACSHA256, + iterationCount: 10000, + numBytesRequested: 32); + + return computedHash.SequenceEqual(storedHash); + } +} \ No newline at end of file diff --git a/FinancialTracker/appsettings.json b/FinancialTracker/appsettings.json index 10f68b8..249bf85 100644 --- a/FinancialTracker/appsettings.json +++ b/FinancialTracker/appsettings.json @@ -5,5 +5,12 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "JwtSettings": { + "Secret": "kL3nBvC6xZ8qW4eR7tY2uI9oP0aSs5dF1", + "Issuer": "FinancialTracker", + "Audience": "FinancialTrackerUsers", + "AccessTokenExpirationMinutes": 15, + "RefreshTokenExpirationDays": 30 + } }