From 26cf34c94e3c9a178a2f34e592f5486f40509b39 Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Thu, 15 Aug 2024 18:03:55 +0200 Subject: [PATCH 01/12] feat: #8 email sending workflow --- ... => 20240815155146_BaseEntity.Designer.cs} | 13 ++-- ...Entity.cs => 20240815155146_BaseEntity.cs} | 9 +-- .../DatabaseContextModelSnapshot.cs | 9 ++- rag-2-backend/Models/Entity/RecordedGame.cs | 2 +- rag-2-backend/Models/Entity/User.cs | 22 ++++++- rag-2-backend/Program.cs | 2 + rag-2-backend/Services/UserService.cs | 32 +++++----- rag-2-backend/Test/GameRecordServiceTest.cs | 7 +-- rag-2-backend/Test/GameServiceTest.cs | 3 +- rag-2-backend/Test/UserServiceTest.cs | 62 +++++++++++++++++++ rag-2-backend/Test/UserTest.cs | 24 +++++++ rag-2-backend/Utils/EmailSendingUtil.cs | 38 ++++++++++++ rag-2-backend/Utils/JwtUtil.cs | 2 +- rag-2-backend/Utils/MailSettings.cs | 12 ++++ rag-2-backend/appsettings.json | 13 +++- rag-2-backend/rag-2-backend.csproj | 1 + 16 files changed, 215 insertions(+), 36 deletions(-) rename rag-2-backend/Migrations/{20240802153730_BasicEntity.Designer.cs => 20240815155146_BaseEntity.Designer.cs} (90%) rename rag-2-backend/Migrations/{20240802153730_BasicEntity.cs => 20240815155146_BaseEntity.cs} (90%) create mode 100644 rag-2-backend/Test/UserServiceTest.cs create mode 100644 rag-2-backend/Test/UserTest.cs create mode 100644 rag-2-backend/Utils/EmailSendingUtil.cs create mode 100644 rag-2-backend/Utils/MailSettings.cs diff --git a/rag-2-backend/Migrations/20240802153730_BasicEntity.Designer.cs b/rag-2-backend/Migrations/20240815155146_BaseEntity.Designer.cs similarity index 90% rename from rag-2-backend/Migrations/20240802153730_BasicEntity.Designer.cs rename to rag-2-backend/Migrations/20240815155146_BaseEntity.Designer.cs index 61f7a3a..ecbe0cf 100644 --- a/rag-2-backend/Migrations/20240802153730_BasicEntity.Designer.cs +++ b/rag-2-backend/Migrations/20240815155146_BaseEntity.Designer.cs @@ -11,8 +11,8 @@ namespace rag_2_backend.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20240802153730_BasicEntity")] - partial class BasicEntity + [Migration("20240815155146_BaseEntity")] + partial class BaseEntity { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -32,13 +32,18 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("Confirmed") + .HasColumnType("boolean"); + b.Property("Email") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(100) + .HasColumnType("character varying(100)"); b.Property("Password") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(100) + .HasColumnType("character varying(100)"); b.Property("Role") .HasColumnType("integer"); diff --git a/rag-2-backend/Migrations/20240802153730_BasicEntity.cs b/rag-2-backend/Migrations/20240815155146_BaseEntity.cs similarity index 90% rename from rag-2-backend/Migrations/20240802153730_BasicEntity.cs rename to rag-2-backend/Migrations/20240815155146_BaseEntity.cs index d662d6e..1a5734e 100644 --- a/rag-2-backend/Migrations/20240802153730_BasicEntity.cs +++ b/rag-2-backend/Migrations/20240815155146_BaseEntity.cs @@ -6,7 +6,7 @@ namespace rag_2_backend.Migrations { /// - public partial class BasicEntity : Migration + public partial class BaseEntity : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -31,9 +31,10 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column(type: "integer", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Email = table.Column(type: "text", nullable: false), - Password = table.Column(type: "text", nullable: false), - Role = table.Column(type: "integer", nullable: false) + Email = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Password = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Role = table.Column(type: "integer", nullable: false), + Confirmed = table.Column(type: "boolean", nullable: false) }, constraints: table => { diff --git a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs index 2fb91eb..77bc320 100644 --- a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs +++ b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs @@ -29,13 +29,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("Confirmed") + .HasColumnType("boolean"); + b.Property("Email") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(100) + .HasColumnType("character varying(100)"); b.Property("Password") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(100) + .HasColumnType("character varying(100)"); b.Property("Role") .HasColumnType("integer"); diff --git a/rag-2-backend/Models/Entity/RecordedGame.cs b/rag-2-backend/Models/Entity/RecordedGame.cs index 21a3b94..46f5d2b 100644 --- a/rag-2-backend/Models/Entity/RecordedGame.cs +++ b/rag-2-backend/Models/Entity/RecordedGame.cs @@ -9,6 +9,6 @@ public class RecordedGame { [Key] public int Id { get; init; } public required Game Game { get; init; } - [MaxLength(100)] public required string Value { get; init; } + public required string Value { get; init; } public required User User { get; init; } } \ No newline at end of file diff --git a/rag-2-backend/Models/Entity/User.cs b/rag-2-backend/Models/Entity/User.cs index 8173584..9420ada 100644 --- a/rag-2-backend/Models/Entity/User.cs +++ b/rag-2-backend/Models/Entity/User.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using rag_2_backend.Services; namespace rag_2_backend.Models.Entity; @@ -7,7 +8,24 @@ namespace rag_2_backend.Models.Entity; public class User { [Key] public int Id { get; init; } - [MaxLength(100)] public required string Email { get; init; } + [MaxLength(100)] public string Email { get; set; } [MaxLength(100)] public required string Password { get; init; } - public Role Role { get; set; } = Role.Student; + public Role Role { get; set; } + public bool Confirmed { get; set; } = false; + + public User() + { + Email = ""; + } + + public User(string email) + { + var domain = email.Split('@')[1]; + + if (!domain.Equals("stud.prz.edu.pl") && !domain.Equals("prz.edu.pl")) + throw new BadHttpRequestException("Wrong domain"); + + Role = domain.Equals("stud.prz.edu.pl") ? Role.Student : Role.Teacher; + Email = email; + } } diff --git a/rag-2-backend/Program.cs b/rag-2-backend/Program.cs index 83691ff..9c49edf 100644 --- a/rag-2-backend/Program.cs +++ b/rag-2-backend/Program.cs @@ -71,6 +71,7 @@ { options.AddDefaultPolicy(builder => builder.WithOrigins("http://localhost:4200").AllowAnyMethod().AllowAnyHeader().AllowCredentials()); }); +builder.Services.Configure(builder.Configuration.GetSection("MailSettings")); builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); @@ -78,6 +79,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/rag-2-backend/Services/UserService.cs b/rag-2-backend/Services/UserService.cs index e75bff2..21814ce 100644 --- a/rag-2-backend/Services/UserService.cs +++ b/rag-2-backend/Services/UserService.cs @@ -7,29 +7,22 @@ namespace rag_2_backend.Services; -public class UserService(DatabaseContext context, JwtUtil jwtUtil) +public class UserService(DatabaseContext context, JwtUtil jwtUtil, EmailSendingUtil emailSendingUtil) { - public async void RegisterUser(UserRequest userRequest) + public void RegisterUser(UserRequest userRequest) { if (context.Users.Any(u => u.Email == userRequest.Email)) - throw new ArgumentException("User already exists"); + throw new BadHttpRequestException("User already exists"); - - User user = new() + User user = new(userRequest.Email) { - Email = userRequest.Email, Password = HashUtil.HashPassword(userRequest.Password) }; - await context.Users.AddAsync(user); - await context.SaveChangesAsync(); - } + context.Users.Add(user); + context.SaveChanges(); - public async Task GetMe(string email) - { - var user = await context.Users.FirstOrDefaultAsync(u => u.Email == email) ?? throw new KeyNotFoundException("User not found"); - - return UserMapper.Map(user); + emailSendingUtil.SendMail("marcinbator.ofc@gmail.com", "Marcinbator Ofc", user.Email); } public async Task LoginUser(string email, string password) @@ -38,13 +31,22 @@ public async Task LoginUser(string email, string password) if (!HashUtil.VerifyPassword(password, user.Password)) throw new UnauthorizedAccessException("Invalid password"); + if (!user.Confirmed) + throw new UnauthorizedAccessException("Confirm email"); return jwtUtil.GenerateToken(user.Email, user.Role.ToString()); } + public async Task GetMe(string email) + { + var user = await context.Users.FirstOrDefaultAsync(u => u.Email == email) ?? throw new KeyNotFoundException("User not found"); + + return UserMapper.Map(user); + } + public void LogoutUser(string email) { - // Redis queueing of blackisted tokens + // Redis queueing of blacklisted tokens } } diff --git a/rag-2-backend/Test/GameRecordServiceTest.cs b/rag-2-backend/Test/GameRecordServiceTest.cs index 12b4c97..10972b0 100644 --- a/rag-2-backend/Test/GameRecordServiceTest.cs +++ b/rag-2-backend/Test/GameRecordServiceTest.cs @@ -18,10 +18,9 @@ public class GameRecordServiceTest new DbContextOptionsBuilder().Options ); private readonly GameRecordService _gameRecordService; - private readonly User _user = new() + private readonly User _user = new("email@prz.edu.pl") { Id = 1, - Email = "email", Password = "password", }; private readonly Game _game = new() @@ -63,7 +62,7 @@ public async void GetRecordsByGameTest() Id = 1, Value = "10", GameResponse = new GameResponse { Id = 1 , Name = "Game1", GameType = GameType.EventGame }, - UserResponse = new UserResponse { Id = 1, Email = "email", Role = Role.Student }, + UserResponse = new UserResponse { Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher }, }, ]; @@ -78,7 +77,7 @@ public async void GetRecordsByGameTest() public void AddGameRecordTest() { var request = new RecordedGameRequest { GameId = 1, Value = "10" }; - _gameRecordService.AddGameRecord(request, "email"); + _gameRecordService.AddGameRecord(request, "email@prz.edu.pl"); _contextMock.Verify(c => c.RecordedGames.Add(It.IsAny()), Times.Once); } diff --git a/rag-2-backend/Test/GameServiceTest.cs b/rag-2-backend/Test/GameServiceTest.cs index ede34d5..8af93b5 100644 --- a/rag-2-backend/Test/GameServiceTest.cs +++ b/rag-2-backend/Test/GameServiceTest.cs @@ -86,9 +86,8 @@ public void ShouldThrowBadRequestIfGameHasRecords() List records = [new RecordedGame { Game = _games[0], - User = new User + User = new User("email@prz.edu.pl") { - Email = "email", Password = "pass" }, Value = "value" diff --git a/rag-2-backend/Test/UserServiceTest.cs b/rag-2-backend/Test/UserServiceTest.cs new file mode 100644 index 0000000..061cedd --- /dev/null +++ b/rag-2-backend/Test/UserServiceTest.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using MockQueryable.Moq; +using Moq; +using rag_2_backend.data; +using rag_2_backend.DTO; +using rag_2_backend.Models.Entity; +using rag_2_backend.Services; +using rag_2_backend.Utils; +using Xunit; + +namespace rag_2_backend.Test; + +public class UserServiceTest +{ + private readonly Mock _contextMock = new( + new DbContextOptionsBuilder().Options + ); + private readonly Mock _jwtUtilMock = new(null); + private readonly Mock _emailSendingUtil = new(null); + private readonly UserService _userService; + private readonly User _user = new("email@prz.edu.pl") + { + Id = 1, + Password = HashUtil.HashPassword("password"), + }; + + public UserServiceTest() + { + _userService = new UserService(_contextMock.Object, _jwtUtilMock.Object, _emailSendingUtil.Object); + + _contextMock.Setup(c => c.Users).Returns(() => new List { _user } + .AsQueryable().BuildMockDbSet().Object); + _jwtUtilMock.Setup(j=>j.GenerateToken(It.IsAny(), It.IsAny())).Verifiable(); + } + + [Fact] + public void ShouldRegisterUser() + { + _userService.RegisterUser(new UserRequest + {Email = "email1@prz.edu.pl", Password = "pass"} + ); + + _contextMock.Verify(c=>c.Users.Add(It.IsAny()), Times.Once); + } + + // [Fact] + // public void ShouldLoginUser() + // { + // Assert.ThrowsAsync( + // () => _userService.LoginUser("email@prz.edu.pl", "pass") + // ); //wrong password + // + // Assert.ThrowsAsync( + // () => _userService.LoginUser("email@prz.edu.pl", "password") + // ); //not confirmed + // + // _user.Confirmed = true; + // _userService.LoginUser("email@prz.edu.pl", "password"); + // _jwtUtilMock.Verify(j=>j.GenerateToken(It.IsAny(), It.IsAny()), Times.Once); + // } + +} \ No newline at end of file diff --git a/rag-2-backend/Test/UserTest.cs b/rag-2-backend/Test/UserTest.cs new file mode 100644 index 0000000..54ed88e --- /dev/null +++ b/rag-2-backend/Test/UserTest.cs @@ -0,0 +1,24 @@ +using rag_2_backend.Models; +using rag_2_backend.Models.Entity; +using Xunit; + +namespace rag_2_backend.Test; + +public class UserTest +{ + [Fact] + public void ShouldCreateUser() + { + Assert.Equal( + Role.Student, + new User("index@stud.prz.edu.pl"){Password = "pass"}.Role + ); + Assert.Equal( + Role.Teacher, + new User("index@prz.edu.pl"){Password = "pass"}.Role + ); + Assert.Throws( + () => new User("index@gmail.com"){Password = "pass"} + ); + } +} \ No newline at end of file diff --git a/rag-2-backend/Utils/EmailSendingUtil.cs b/rag-2-backend/Utils/EmailSendingUtil.cs new file mode 100644 index 0000000..4802add --- /dev/null +++ b/rag-2-backend/Utils/EmailSendingUtil.cs @@ -0,0 +1,38 @@ +using MailKit.Net.Smtp; +using Microsoft.Extensions.Options; +using MimeKit; + +namespace rag_2_backend.Utils; + +public class EmailSendingUtil(IOptions options) +{ + private readonly MailSettings _mailSettings = options.Value; + + public bool SendMail(string to, string subject, string body) + { + try + { + var emailMessage = new MimeMessage(); + var emailFrom = new MailboxAddress(_mailSettings.Name, _mailSettings.EmailId); + emailMessage.From.Add(emailFrom); + var emailTo = new MailboxAddress(to, to); + emailMessage.To.Add(emailTo); + emailMessage.Subject = subject; + var emailBodyBuilder = new BodyBuilder(); + emailBodyBuilder.TextBody = body; + emailMessage.Body = emailBodyBuilder.ToMessageBody(); + + var mailClient = new SmtpClient(); + mailClient.Connect(_mailSettings.Host, _mailSettings.Port, _mailSettings.UseSSL); + mailClient.Authenticate(_mailSettings.EmailId, _mailSettings.Password); + mailClient.Send(emailMessage); + mailClient.Disconnect(true); + mailClient.Dispose(); + return true; + } + catch(Exception ex) + { + return false; + } + } +} \ No newline at end of file diff --git a/rag-2-backend/Utils/JwtUtil.cs b/rag-2-backend/Utils/JwtUtil.cs index a888533..f6ed4b4 100644 --- a/rag-2-backend/Utils/JwtUtil.cs +++ b/rag-2-backend/Utils/JwtUtil.cs @@ -8,7 +8,7 @@ namespace rag_2_backend.Utils; public class JwtUtil(IConfiguration config) { - public string GenerateToken(string email, string role) + public virtual string GenerateToken(string email, string role) { var jwtKey = config["Jwt:Key"]; var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey ?? "")); diff --git a/rag-2-backend/Utils/MailSettings.cs b/rag-2-backend/Utils/MailSettings.cs new file mode 100644 index 0000000..4275f99 --- /dev/null +++ b/rag-2-backend/Utils/MailSettings.cs @@ -0,0 +1,12 @@ +namespace rag_2_backend.Utils; + +public class MailSettings +{ + public string EmailId { get; set; } + public string Name { get; set; } + public string UserName { get; set; } + public string Password { get; set; } + public string Host { get; set; } + public int Port { get; set; } + public bool UseSSL { get; set; } +} \ No newline at end of file diff --git a/rag-2-backend/appsettings.json b/rag-2-backend/appsettings.json index 5c97c13..2623afa 100644 --- a/rag-2-backend/appsettings.json +++ b/rag-2-backend/appsettings.json @@ -11,8 +11,19 @@ }, "Jwt": { "Key": "MIHcAgEBBEIBIHk9RDBDHoudslGHKpNqh8Tsr7fWu0J", - "Issuer": "batorbuczek.pl", + "Issuer": "immortalcoders.com", "ExpireMinutes": 60 + }, + "MailSettings": + { + "Host": "smtp.gmail.com", + "DefaultCredentials": false, + "Port": 587, + "Name": "RAG-2", + "EmailId": "the.immortalcoders@gmail.com", + "UserName": "the.immortalcoders@gmail.com", + "Password": "fzozhrthfueuuorm", + "UseSSL": false } } diff --git a/rag-2-backend/rag-2-backend.csproj b/rag-2-backend/rag-2-backend.csproj index 112242d..36525b4 100644 --- a/rag-2-backend/rag-2-backend.csproj +++ b/rag-2-backend/rag-2-backend.csproj @@ -12,6 +12,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive From 8c893e5002b9e9db2f321c0229aa3af77613308c Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Thu, 15 Aug 2024 18:09:27 +0200 Subject: [PATCH 02/12] refactor: #8 async mail sending --- rag-2-backend/Services/UserService.cs | 12 +++++++----- rag-2-backend/Utils/EmailSendingUtil.cs | 22 ++++++++++++---------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/rag-2-backend/Services/UserService.cs b/rag-2-backend/Services/UserService.cs index 21814ce..cf90849 100644 --- a/rag-2-backend/Services/UserService.cs +++ b/rag-2-backend/Services/UserService.cs @@ -22,12 +22,14 @@ public void RegisterUser(UserRequest userRequest) context.Users.Add(user); context.SaveChanges(); - emailSendingUtil.SendMail("marcinbator.ofc@gmail.com", "Marcinbator Ofc", user.Email); + Task.Run(async () => + await emailSendingUtil.SendMail("marcinbator.ofc@gmail.com", "Marcinbator Ofc", user.Email)); } public async Task LoginUser(string email, string password) { - var user = await context.Users.FirstOrDefaultAsync(u => u.Email == email) ?? throw new KeyNotFoundException("User not found"); + var user = await context.Users.FirstOrDefaultAsync(u => u.Email == email) ?? + throw new KeyNotFoundException("User not found"); if (!HashUtil.VerifyPassword(password, user.Password)) throw new UnauthorizedAccessException("Invalid password"); @@ -39,7 +41,8 @@ public async Task LoginUser(string email, string password) public async Task GetMe(string email) { - var user = await context.Users.FirstOrDefaultAsync(u => u.Email == email) ?? throw new KeyNotFoundException("User not found"); + var user = await context.Users.FirstOrDefaultAsync(u => u.Email == email) ?? + throw new KeyNotFoundException("User not found"); return UserMapper.Map(user); } @@ -48,5 +51,4 @@ public void LogoutUser(string email) { // Redis queueing of blacklisted tokens } -} - +} \ No newline at end of file diff --git a/rag-2-backend/Utils/EmailSendingUtil.cs b/rag-2-backend/Utils/EmailSendingUtil.cs index 4802add..1aef572 100644 --- a/rag-2-backend/Utils/EmailSendingUtil.cs +++ b/rag-2-backend/Utils/EmailSendingUtil.cs @@ -8,7 +8,7 @@ public class EmailSendingUtil(IOptions options) { private readonly MailSettings _mailSettings = options.Value; - public bool SendMail(string to, string subject, string body) + public async Task SendMail(string to, string subject, string body) { try { @@ -18,19 +18,21 @@ public bool SendMail(string to, string subject, string body) var emailTo = new MailboxAddress(to, to); emailMessage.To.Add(emailTo); emailMessage.Subject = subject; - var emailBodyBuilder = new BodyBuilder(); - emailBodyBuilder.TextBody = body; + var emailBodyBuilder = new BodyBuilder + { + TextBody = body + }; emailMessage.Body = emailBodyBuilder.ToMessageBody(); - var mailClient = new SmtpClient(); - mailClient.Connect(_mailSettings.Host, _mailSettings.Port, _mailSettings.UseSSL); - mailClient.Authenticate(_mailSettings.EmailId, _mailSettings.Password); - mailClient.Send(emailMessage); - mailClient.Disconnect(true); - mailClient.Dispose(); + using var mailClient = new SmtpClient(); + await mailClient.ConnectAsync(_mailSettings.Host, _mailSettings.Port, _mailSettings.UseSSL); + await mailClient.AuthenticateAsync(_mailSettings.EmailId, _mailSettings.Password); + await mailClient.SendAsync(emailMessage); + await mailClient.DisconnectAsync(true); + return true; } - catch(Exception ex) + catch (Exception ex) { return false; } From 4abc3261937baf7c171e67094e9df99d896497bd Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Thu, 15 Aug 2024 18:33:01 +0200 Subject: [PATCH 03/12] feat: #8 confirmation main sending workflow --- rag-2-backend/Data/DatabaseContext.cs | 1 + ...62641_AccountConfirmationToken.Designer.cs | 159 ++++++++++++++++++ ...20240815162641_AccountConfirmationToken.cs | 46 +++++ .../DatabaseContextModelSnapshot.cs | 31 ++++ .../Models/Entity/AccountConfirmationToken.cs | 10 ++ rag-2-backend/Models/Entity/User.cs | 8 +- rag-2-backend/Program.cs | 3 + rag-2-backend/Services/EmailService.cs | 16 ++ rag-2-backend/Services/UserService.cs | 12 +- rag-2-backend/Test/UserServiceTest.cs | 4 +- rag-2-backend/Utils/EmailSendingUtil.cs | 2 +- rag-2-backend/appsettings.Development.json | 3 + 12 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.Designer.cs create mode 100644 rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.cs create mode 100644 rag-2-backend/Models/Entity/AccountConfirmationToken.cs create mode 100644 rag-2-backend/Services/EmailService.cs diff --git a/rag-2-backend/Data/DatabaseContext.cs b/rag-2-backend/Data/DatabaseContext.cs index b21473f..c58e80c 100644 --- a/rag-2-backend/Data/DatabaseContext.cs +++ b/rag-2-backend/Data/DatabaseContext.cs @@ -11,4 +11,5 @@ public class DatabaseContext(DbContextOptions options) : DbCont public virtual required DbSet RecordedGames { get; init; } public virtual required DbSet Users { get; init; } + public virtual required DbSet AccountConfirmationTokens { get; init; } } \ No newline at end of file diff --git a/rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.Designer.cs b/rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.Designer.cs new file mode 100644 index 0000000..3fa4d85 --- /dev/null +++ b/rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.Designer.cs @@ -0,0 +1,159 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using rag_2_backend.data; + +#nullable disable + +namespace rag_2_backend.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20240815162641_AccountConfirmationToken")] + partial class AccountConfirmationToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("AccountConfirmationTokens"); + }); + + modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Confirmed") + .HasColumnType("boolean"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("users"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("games"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId"); + + b.ToTable("recorded_games"); + }); + + modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => + { + b.HasOne("rag_2_backend.Models.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => + { + b.HasOne("rag_2_backend.models.entity.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("rag_2_backend.Models.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.cs b/rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.cs new file mode 100644 index 0000000..b851870 --- /dev/null +++ b/rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace rag_2_backend.Migrations +{ + /// + public partial class AccountConfirmationToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AccountConfirmationTokens", + columns: table => new + { + Token = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Expiration = table.Column(type: "timestamp with time zone", nullable: false), + UserId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountConfirmationTokens", x => x.Token); + table.ForeignKey( + name: "FK_AccountConfirmationTokens_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AccountConfirmationTokens_UserId", + table: "AccountConfirmationTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccountConfirmationTokens"); + } + } +} diff --git a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs index 77bc320..c8afaf3 100644 --- a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs +++ b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -21,6 +22,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("AccountConfirmationTokens"); + }); + modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => { b.Property("Id") @@ -101,6 +121,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("recorded_games"); }); + modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => + { + b.HasOne("rag_2_backend.Models.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => { b.HasOne("rag_2_backend.models.entity.Game", "Game") diff --git a/rag-2-backend/Models/Entity/AccountConfirmationToken.cs b/rag-2-backend/Models/Entity/AccountConfirmationToken.cs new file mode 100644 index 0000000..f27299f --- /dev/null +++ b/rag-2-backend/Models/Entity/AccountConfirmationToken.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace rag_2_backend.Models.Entity; + +public class AccountConfirmationToken +{ + [Key] [MaxLength(100)] public required string Token { get; init; } + public required DateTime Expiration { get; init; } + public required User User { get; init; } +} \ No newline at end of file diff --git a/rag-2-backend/Models/Entity/User.cs b/rag-2-backend/Models/Entity/User.cs index 9420ada..7e6fbff 100644 --- a/rag-2-backend/Models/Entity/User.cs +++ b/rag-2-backend/Models/Entity/User.cs @@ -8,15 +8,13 @@ namespace rag_2_backend.Models.Entity; public class User { [Key] public int Id { get; init; } - [MaxLength(100)] public string Email { get; set; } + [MaxLength(100)] public string Email { get; set; } = ""; [MaxLength(100)] public required string Password { get; init; } public Role Role { get; set; } public bool Confirmed { get; set; } = false; - public User() - { - Email = ""; - } + public User() //for ef + {} public User(string email) { diff --git a/rag-2-backend/Program.cs b/rag-2-backend/Program.cs index 9c49edf..030d216 100644 --- a/rag-2-backend/Program.cs +++ b/rag-2-backend/Program.cs @@ -15,6 +15,8 @@ var jwtIssuer = builder.Configuration.GetSection("Jwt:Issuer").Get(); var jwtKey = builder.Configuration.GetSection("Jwt:Key").Get(); +AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { @@ -80,6 +82,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/rag-2-backend/Services/EmailService.cs b/rag-2-backend/Services/EmailService.cs new file mode 100644 index 0000000..4665be5 --- /dev/null +++ b/rag-2-backend/Services/EmailService.cs @@ -0,0 +1,16 @@ +using rag_2_backend.Utils; + +namespace rag_2_backend.Services; + +public class EmailService(EmailSendingUtil emailSendingUtil, IConfiguration config) +{ + public void SendConfirmationEmail(string to, string token) + { + var address = config.GetValue("FrontendURLs:MailConfirmationURL") + token; + var body = "Please confirm your email address by clicking this button: Confirm"; + + Task.Run(async () => + await emailSendingUtil.SendMail(to, "Confirmation email", body)); + } +} \ No newline at end of file diff --git a/rag-2-backend/Services/UserService.cs b/rag-2-backend/Services/UserService.cs index cf90849..ba64632 100644 --- a/rag-2-backend/Services/UserService.cs +++ b/rag-2-backend/Services/UserService.cs @@ -7,7 +7,7 @@ namespace rag_2_backend.Services; -public class UserService(DatabaseContext context, JwtUtil jwtUtil, EmailSendingUtil emailSendingUtil) +public class UserService(DatabaseContext context, JwtUtil jwtUtil, EmailService emailService) { public void RegisterUser(UserRequest userRequest) { @@ -18,12 +18,18 @@ public void RegisterUser(UserRequest userRequest) { Password = HashUtil.HashPassword(userRequest.Password) }; + var token = new AccountConfirmationToken() + { + Token = HashUtil.HashPassword(user.Email + user.Password + DateTime.Now), + User = user, + Expiration = DateTime.Now.AddDays(7) + }; context.Users.Add(user); + context.AccountConfirmationTokens.Add(token); context.SaveChanges(); - Task.Run(async () => - await emailSendingUtil.SendMail("marcinbator.ofc@gmail.com", "Marcinbator Ofc", user.Email)); + emailService.SendConfirmationEmail(user.Email, token.Token); } public async Task LoginUser(string email, string password) diff --git a/rag-2-backend/Test/UserServiceTest.cs b/rag-2-backend/Test/UserServiceTest.cs index 061cedd..ac96339 100644 --- a/rag-2-backend/Test/UserServiceTest.cs +++ b/rag-2-backend/Test/UserServiceTest.cs @@ -16,7 +16,7 @@ public class UserServiceTest new DbContextOptionsBuilder().Options ); private readonly Mock _jwtUtilMock = new(null); - private readonly Mock _emailSendingUtil = new(null); + private readonly Mock _emailService = new(null); private readonly UserService _userService; private readonly User _user = new("email@prz.edu.pl") { @@ -26,7 +26,7 @@ public class UserServiceTest public UserServiceTest() { - _userService = new UserService(_contextMock.Object, _jwtUtilMock.Object, _emailSendingUtil.Object); + _userService = new UserService(_contextMock.Object, _jwtUtilMock.Object, _emailService.Object); _contextMock.Setup(c => c.Users).Returns(() => new List { _user } .AsQueryable().BuildMockDbSet().Object); diff --git a/rag-2-backend/Utils/EmailSendingUtil.cs b/rag-2-backend/Utils/EmailSendingUtil.cs index 1aef572..2d8898c 100644 --- a/rag-2-backend/Utils/EmailSendingUtil.cs +++ b/rag-2-backend/Utils/EmailSendingUtil.cs @@ -20,7 +20,7 @@ public async Task SendMail(string to, string subject, string body) emailMessage.Subject = subject; var emailBodyBuilder = new BodyBuilder { - TextBody = body + HtmlBody = body }; emailMessage.Body = emailBodyBuilder.ToMessageBody(); diff --git a/rag-2-backend/appsettings.Development.json b/rag-2-backend/appsettings.Development.json index e9567c3..5f343e6 100644 --- a/rag-2-backend/appsettings.Development.json +++ b/rag-2-backend/appsettings.Development.json @@ -7,5 +7,8 @@ }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" + }, + "FrontendURLs": { + "MailConfirmationURL": "http://localhost:4200/user/confirm/?token=" } } From e7133b9d66d6d6cc669770feb2bd69b434e22333 Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Thu, 15 Aug 2024 18:59:11 +0200 Subject: [PATCH 04/12] feat: #8 email confirmation submitting workflow --- rag-2-backend/Controllers/UserController.cs | 6 + .../20240815155146_BaseEntity.Designer.cs | 128 ------------------ ...20240815162641_AccountConfirmationToken.cs | 46 ------- ... => 20240815163557_BaseEntity.Designer.cs} | 8 +- ...Entity.cs => 20240815163557_BaseEntity.cs} | 30 +++- .../DatabaseContextModelSnapshot.cs | 4 +- .../Models/Entity/AccountConfirmationToken.cs | 2 + rag-2-backend/Services/UserService.cs | 18 ++- rag-2-backend/Utils/TokenGenerationUtil.cs | 18 +++ rag-2-backend/rag-2-backend.csproj | 3 + 10 files changed, 81 insertions(+), 182 deletions(-) delete mode 100644 rag-2-backend/Migrations/20240815155146_BaseEntity.Designer.cs delete mode 100644 rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.cs rename rag-2-backend/Migrations/{20240815162641_AccountConfirmationToken.Designer.cs => 20240815163557_BaseEntity.Designer.cs} (95%) rename rag-2-backend/Migrations/{20240815155146_BaseEntity.cs => 20240815163557_BaseEntity.cs} (75%) create mode 100644 rag-2-backend/Utils/TokenGenerationUtil.cs diff --git a/rag-2-backend/Controllers/UserController.cs b/rag-2-backend/Controllers/UserController.cs index 05a5952..fb1a4e2 100644 --- a/rag-2-backend/Controllers/UserController.cs +++ b/rag-2-backend/Controllers/UserController.cs @@ -31,6 +31,12 @@ public void Logout() userService.LogoutUser(email); } + [HttpPost("auth/confirm")] + public void ConfirmAccount([Required] string token) + { + userService.ConfirmAccount(token); + } + /// /// (Autneticated) /// diff --git a/rag-2-backend/Migrations/20240815155146_BaseEntity.Designer.cs b/rag-2-backend/Migrations/20240815155146_BaseEntity.Designer.cs deleted file mode 100644 index ecbe0cf..0000000 --- a/rag-2-backend/Migrations/20240815155146_BaseEntity.Designer.cs +++ /dev/null @@ -1,128 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using rag_2_backend.data; - -#nullable disable - -namespace rag_2_backend.Migrations -{ - [DbContext(typeof(DatabaseContext))] - [Migration("20240815155146_BaseEntity")] - partial class BaseEntity - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Confirmed") - .HasColumnType("boolean"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Password") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Role") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("users"); - }); - - modelBuilder.Entity("rag_2_backend.models.entity.Game", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("GameType") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("games"); - }); - - modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("GameId") - .HasColumnType("integer"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.Property("Value") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("GameId"); - - b.HasIndex("UserId"); - - b.ToTable("recorded_games"); - }); - - modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => - { - b.HasOne("rag_2_backend.models.entity.Game", "Game") - .WithMany() - .HasForeignKey("GameId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("rag_2_backend.Models.Entity.User", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Game"); - - b.Navigation("User"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.cs b/rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.cs deleted file mode 100644 index b851870..0000000 --- a/rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace rag_2_backend.Migrations -{ - /// - public partial class AccountConfirmationToken : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "AccountConfirmationTokens", - columns: table => new - { - Token = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - Expiration = table.Column(type: "timestamp with time zone", nullable: false), - UserId = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AccountConfirmationTokens", x => x.Token); - table.ForeignKey( - name: "FK_AccountConfirmationTokens_users_UserId", - column: x => x.UserId, - principalTable: "users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_AccountConfirmationTokens_UserId", - table: "AccountConfirmationTokens", - column: "UserId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AccountConfirmationTokens"); - } - } -} diff --git a/rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.Designer.cs b/rag-2-backend/Migrations/20240815163557_BaseEntity.Designer.cs similarity index 95% rename from rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.Designer.cs rename to rag-2-backend/Migrations/20240815163557_BaseEntity.Designer.cs index 3fa4d85..ab0d03e 100644 --- a/rag-2-backend/Migrations/20240815162641_AccountConfirmationToken.Designer.cs +++ b/rag-2-backend/Migrations/20240815163557_BaseEntity.Designer.cs @@ -12,8 +12,8 @@ namespace rag_2_backend.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20240815162641_AccountConfirmationToken")] - partial class AccountConfirmationToken + [Migration("20240815163557_BaseEntity")] + partial class BaseEntity { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -32,7 +32,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("character varying(100)"); b.Property("Expiration") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("UserId") .HasColumnType("integer"); @@ -41,7 +41,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("UserId"); - b.ToTable("AccountConfirmationTokens"); + b.ToTable("account_confirmation_token"); }); modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => diff --git a/rag-2-backend/Migrations/20240815155146_BaseEntity.cs b/rag-2-backend/Migrations/20240815163557_BaseEntity.cs similarity index 75% rename from rag-2-backend/Migrations/20240815155146_BaseEntity.cs rename to rag-2-backend/Migrations/20240815163557_BaseEntity.cs index 1a5734e..b7d9b8a 100644 --- a/rag-2-backend/Migrations/20240815155146_BaseEntity.cs +++ b/rag-2-backend/Migrations/20240815163557_BaseEntity.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using System; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -41,6 +42,25 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("PK_users", x => x.Id); }); + migrationBuilder.CreateTable( + name: "account_confirmation_token", + columns: table => new + { + Token = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Expiration = table.Column(type: "timestamp without time zone", nullable: false), + UserId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_account_confirmation_token", x => x.Token); + table.ForeignKey( + name: "FK_account_confirmation_token_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "recorded_games", columns: table => new @@ -68,6 +88,11 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateIndex( + name: "IX_account_confirmation_token_UserId", + table: "account_confirmation_token", + column: "UserId"); + migrationBuilder.CreateIndex( name: "IX_games_Name", table: "games", @@ -88,6 +113,9 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "account_confirmation_token"); + migrationBuilder.DropTable( name: "recorded_games"); diff --git a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs index c8afaf3..562be9b 100644 --- a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs +++ b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs @@ -29,7 +29,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(100)"); b.Property("Expiration") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("UserId") .HasColumnType("integer"); @@ -38,7 +38,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("UserId"); - b.ToTable("AccountConfirmationTokens"); + b.ToTable("account_confirmation_token"); }); modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => diff --git a/rag-2-backend/Models/Entity/AccountConfirmationToken.cs b/rag-2-backend/Models/Entity/AccountConfirmationToken.cs index f27299f..4a43c1f 100644 --- a/rag-2-backend/Models/Entity/AccountConfirmationToken.cs +++ b/rag-2-backend/Models/Entity/AccountConfirmationToken.cs @@ -1,7 +1,9 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace rag_2_backend.Models.Entity; +[Table("account_confirmation_token")] public class AccountConfirmationToken { [Key] [MaxLength(100)] public required string Token { get; init; } diff --git a/rag-2-backend/Services/UserService.cs b/rag-2-backend/Services/UserService.cs index ba64632..b03bd0e 100644 --- a/rag-2-backend/Services/UserService.cs +++ b/rag-2-backend/Services/UserService.cs @@ -20,7 +20,7 @@ public void RegisterUser(UserRequest userRequest) }; var token = new AccountConfirmationToken() { - Token = HashUtil.HashPassword(user.Email + user.Password + DateTime.Now), + Token = TokenGenerationUtil.CreatePassword(15), User = user, Expiration = DateTime.Now.AddDays(7) }; @@ -32,6 +32,22 @@ public void RegisterUser(UserRequest userRequest) emailService.SendConfirmationEmail(user.Email, token.Token); } + public void ConfirmAccount(string tokenValue) + { + var token = context.AccountConfirmationTokens + .Include(t => t.User) + .SingleOrDefault(t => t.Token == tokenValue) + ?? throw new BadHttpRequestException("Invalid token"); + if (token.Expiration < DateTime.Now) throw new BadHttpRequestException("Invalid token"); + + var user = context.Users.SingleOrDefault(u => u.Email == token.User.Email) ?? + throw new KeyNotFoundException("User not found"); + user.Confirmed = true; + + context.AccountConfirmationTokens.Remove(token); + context.SaveChanges(); + } + public async Task LoginUser(string email, string password) { var user = await context.Users.FirstOrDefaultAsync(u => u.Email == email) ?? diff --git a/rag-2-backend/Utils/TokenGenerationUtil.cs b/rag-2-backend/Utils/TokenGenerationUtil.cs new file mode 100644 index 0000000..5609bff --- /dev/null +++ b/rag-2-backend/Utils/TokenGenerationUtil.cs @@ -0,0 +1,18 @@ +using System.Text; + +namespace rag_2_backend.Utils; + +public class TokenGenerationUtil +{ + public static string CreatePassword(int length) + { + const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; + var res = new StringBuilder(); + var rnd = new Random(); + while (0 < length--) + { + res.Append(valid[rnd.Next(valid.Length)]); + } + return res.ToString(); + } +} \ No newline at end of file diff --git a/rag-2-backend/rag-2-backend.csproj b/rag-2-backend/rag-2-backend.csproj index 36525b4..2e0f9fd 100644 --- a/rag-2-backend/rag-2-backend.csproj +++ b/rag-2-backend/rag-2-backend.csproj @@ -39,5 +39,8 @@ all + + + From f5b0e165445822769a58946e4c147172e6b5913f Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Thu, 15 Aug 2024 19:17:11 +0200 Subject: [PATCH 05/12] feat: #8 account confirmation workflow tests --- rag-2-backend/Controllers/GameController.cs | 4 +- .../Controllers/GameRecordController.cs | 2 +- rag-2-backend/Controllers/UserController.cs | 74 ++++++++--------- rag-2-backend/DTO/Mapper/GameMapper.cs | 2 +- .../DTO/Mapper/RecordedGameMapper.cs | 2 +- rag-2-backend/DTO/Mapper/UserMapper.cs | 3 +- rag-2-backend/DTO/RecordedGameRequest.cs | 2 +- rag-2-backend/DTO/RecordedGameResponse.cs | 3 +- rag-2-backend/DTO/UserRequest.cs | 2 +- rag-2-backend/DTO/UserResponse.cs | 2 +- .../Models/Entity/AccountConfirmationToken.cs | 2 +- rag-2-backend/Models/Entity/User.cs | 5 +- rag-2-backend/Models/GameType.cs | 3 +- rag-2-backend/Models/Role.cs | 2 +- rag-2-backend/Program.cs | 27 ++++--- rag-2-backend/Services/EmailService.cs | 4 +- rag-2-backend/Services/GameRecordService.cs | 4 +- rag-2-backend/Services/GameService.cs | 2 +- rag-2-backend/Test/GameRecordServiceTest.cs | 14 +++- rag-2-backend/Test/GameServiceTest.cs | 37 ++++++--- rag-2-backend/Test/UserServiceTest.cs | 79 ++++++++++++++----- rag-2-backend/Test/UserTest.cs | 6 +- rag-2-backend/Utils/JwtUtil.cs | 3 +- rag-2-backend/Utils/TokenGenerationUtil.cs | 1 + 24 files changed, 174 insertions(+), 111 deletions(-) diff --git a/rag-2-backend/Controllers/GameController.cs b/rag-2-backend/Controllers/GameController.cs index 0cebf1e..bf79e5d 100644 --- a/rag-2-backend/Controllers/GameController.cs +++ b/rag-2-backend/Controllers/GameController.cs @@ -21,7 +21,7 @@ public async Task> GetGames() /// [HttpPost] [Authorize(Roles = "Admin")] - public void Add([FromBody][Required] GameRequest request) + public void Add([FromBody] [Required] GameRequest request) { gameService.AddGame(request); } @@ -31,7 +31,7 @@ public void Add([FromBody][Required] GameRequest request) /// [HttpPut("{id:int}")] [Authorize(Roles = "Admin")] - public void Edit([FromBody][Required] GameRequest request, int id) + public void Edit([FromBody] [Required] GameRequest request, int id) { gameService.EditGame(request, id); } diff --git a/rag-2-backend/Controllers/GameRecordController.cs b/rag-2-backend/Controllers/GameRecordController.cs index 4a95363..e161be4 100644 --- a/rag-2-backend/Controllers/GameRecordController.cs +++ b/rag-2-backend/Controllers/GameRecordController.cs @@ -22,7 +22,7 @@ public async Task> GetRecordsByGame([Required] /// [HttpPost] [Authorize] - public void AddGameRecord([FromBody][Required] RecordedGameRequest request) + public void AddGameRecord([FromBody] [Required] RecordedGameRequest request) { var email = User.FindFirst(ClaimTypes.Email)?.Value ?? throw new KeyNotFoundException("User not found"); diff --git a/rag-2-backend/Controllers/UserController.cs b/rag-2-backend/Controllers/UserController.cs index fb1a4e2..12083a6 100644 --- a/rag-2-backend/Controllers/UserController.cs +++ b/rag-2-backend/Controllers/UserController.cs @@ -11,41 +11,41 @@ namespace rag_2_backend.controllers; [Route("api/[controller]")] public class UserController(UserService userService) : ControllerBase { - [HttpPost("auth/register")] - public void Register([FromBody][Required] UserRequest userRequest) - { - userService.RegisterUser(userRequest); - } - - [HttpPost("auth/login")] - public async Task Login([FromBody][Required] UserRequest loginRequest) - { - return await userService.LoginUser(loginRequest.Email, loginRequest.Password); - } - - [HttpPost("auth/logout")] - public void Logout() - { - var email = (User.FindFirst(ClaimTypes.Email)?.Value) ?? throw new UnauthorizedAccessException("Unauthorized"); - - userService.LogoutUser(email); - } - - [HttpPost("auth/confirm")] - public void ConfirmAccount([Required] string token) - { - userService.ConfirmAccount(token); - } - - /// - /// (Autneticated) - /// - [HttpGet("me")] - [Authorize] - public async Task Me() - { - var email = (User.FindFirst(ClaimTypes.Email)?.Value) ?? throw new UnauthorizedAccessException("Unauthorized"); - - return await userService.GetMe(email); - } + [HttpPost("auth/register")] + public void Register([FromBody] [Required] UserRequest userRequest) + { + userService.RegisterUser(userRequest); + } + + [HttpPost("auth/login")] + public async Task Login([FromBody] [Required] UserRequest loginRequest) + { + return await userService.LoginUser(loginRequest.Email, loginRequest.Password); + } + + [HttpPost("auth/logout")] + public void Logout() + { + var email = (User.FindFirst(ClaimTypes.Email)?.Value) ?? throw new UnauthorizedAccessException("Unauthorized"); + + userService.LogoutUser(email); + } + + [HttpPost("auth/confirm")] + public void ConfirmAccount([Required] string token) + { + userService.ConfirmAccount(token); + } + + /// + /// (Autneticated) + /// + [HttpGet("me")] + [Authorize] + public async Task Me() + { + var email = (User.FindFirst(ClaimTypes.Email)?.Value) ?? throw new UnauthorizedAccessException("Unauthorized"); + + return await userService.GetMe(email); + } } \ No newline at end of file diff --git a/rag-2-backend/DTO/Mapper/GameMapper.cs b/rag-2-backend/DTO/Mapper/GameMapper.cs index fb74544..cb230ce 100644 --- a/rag-2-backend/DTO/Mapper/GameMapper.cs +++ b/rag-2-backend/DTO/Mapper/GameMapper.cs @@ -14,4 +14,4 @@ public static GameResponse Map(Game game) GameType = game.GameType }; } -} +} \ No newline at end of file diff --git a/rag-2-backend/DTO/Mapper/RecordedGameMapper.cs b/rag-2-backend/DTO/Mapper/RecordedGameMapper.cs index 8a1235d..ca71067 100644 --- a/rag-2-backend/DTO/Mapper/RecordedGameMapper.cs +++ b/rag-2-backend/DTO/Mapper/RecordedGameMapper.cs @@ -15,4 +15,4 @@ public static RecordedGameResponse Map(RecordedGame recordedGame) Value = recordedGame.Value }; } -} +} \ No newline at end of file diff --git a/rag-2-backend/DTO/Mapper/UserMapper.cs b/rag-2-backend/DTO/Mapper/UserMapper.cs index 2b40b66..6ac4621 100644 --- a/rag-2-backend/DTO/Mapper/UserMapper.cs +++ b/rag-2-backend/DTO/Mapper/UserMapper.cs @@ -1,4 +1,5 @@ namespace rag_2_backend.DTO.Mapper; + using rag_2_backend.DTO; using rag_2_backend.Models.Entity; @@ -13,4 +14,4 @@ public static UserResponse Map(User user) Role = user.Role }; } -} +} \ No newline at end of file diff --git a/rag-2-backend/DTO/RecordedGameRequest.cs b/rag-2-backend/DTO/RecordedGameRequest.cs index 276d4bd..a8d4ee2 100644 --- a/rag-2-backend/DTO/RecordedGameRequest.cs +++ b/rag-2-backend/DTO/RecordedGameRequest.cs @@ -4,4 +4,4 @@ public class RecordedGameRequest { public required int GameId { get; init; } public required string Value { get; init; } -} +} \ No newline at end of file diff --git a/rag-2-backend/DTO/RecordedGameResponse.cs b/rag-2-backend/DTO/RecordedGameResponse.cs index 50b6617..3c6f180 100644 --- a/rag-2-backend/DTO/RecordedGameResponse.cs +++ b/rag-2-backend/DTO/RecordedGameResponse.cs @@ -1,8 +1,9 @@ namespace rag_2_backend.DTO; + public class RecordedGameResponse { public int Id { get; set; } public required UserResponse UserResponse { get; set; } public required GameResponse GameResponse { get; set; } public required string Value { get; set; } -} +} \ No newline at end of file diff --git a/rag-2-backend/DTO/UserRequest.cs b/rag-2-backend/DTO/UserRequest.cs index f79cc4d..f0d4048 100644 --- a/rag-2-backend/DTO/UserRequest.cs +++ b/rag-2-backend/DTO/UserRequest.cs @@ -4,4 +4,4 @@ public class UserRequest { public required string Email { get; set; } public required string Password { get; set; } -} +} \ No newline at end of file diff --git a/rag-2-backend/DTO/UserResponse.cs b/rag-2-backend/DTO/UserResponse.cs index 4d1dc66..7519663 100644 --- a/rag-2-backend/DTO/UserResponse.cs +++ b/rag-2-backend/DTO/UserResponse.cs @@ -9,4 +9,4 @@ public class UserResponse [Key] public required int Id { get; set; } public required string Email { get; set; } public Role Role { get; set; } -} +} \ No newline at end of file diff --git a/rag-2-backend/Models/Entity/AccountConfirmationToken.cs b/rag-2-backend/Models/Entity/AccountConfirmationToken.cs index 4a43c1f..fece3b0 100644 --- a/rag-2-backend/Models/Entity/AccountConfirmationToken.cs +++ b/rag-2-backend/Models/Entity/AccountConfirmationToken.cs @@ -7,6 +7,6 @@ namespace rag_2_backend.Models.Entity; public class AccountConfirmationToken { [Key] [MaxLength(100)] public required string Token { get; init; } - public required DateTime Expiration { get; init; } + public required DateTime Expiration { get; set; } public required User User { get; init; } } \ No newline at end of file diff --git a/rag-2-backend/Models/Entity/User.cs b/rag-2-backend/Models/Entity/User.cs index 7e6fbff..104156d 100644 --- a/rag-2-backend/Models/Entity/User.cs +++ b/rag-2-backend/Models/Entity/User.cs @@ -14,7 +14,8 @@ public class User public bool Confirmed { get; set; } = false; public User() //for ef - {} + { + } public User(string email) { @@ -26,4 +27,4 @@ public User(string email) Role = domain.Equals("stud.prz.edu.pl") ? Role.Student : Role.Teacher; Email = email; } -} +} \ No newline at end of file diff --git a/rag-2-backend/Models/GameType.cs b/rag-2-backend/Models/GameType.cs index cbcf446..66ffd5d 100644 --- a/rag-2-backend/Models/GameType.cs +++ b/rag-2-backend/Models/GameType.cs @@ -2,5 +2,6 @@ namespace rag_2_backend.Models; public enum GameType { - EventGame, TimeGame + EventGame, + TimeGame } \ No newline at end of file diff --git a/rag-2-backend/Models/Role.cs b/rag-2-backend/Models/Role.cs index d5aa267..cb199a3 100644 --- a/rag-2-backend/Models/Role.cs +++ b/rag-2-backend/Models/Role.cs @@ -6,4 +6,4 @@ public enum Role Teacher, Special, Admin, -} +} \ No newline at end of file diff --git a/rag-2-backend/Program.cs b/rag-2-backend/Program.cs index 030d216..3d2145b 100644 --- a/rag-2-backend/Program.cs +++ b/rag-2-backend/Program.cs @@ -18,19 +18,19 @@ AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - ValidIssuer = jwtIssuer, - ValidAudience = jwtIssuer, - ValidateLifetime = true, - ClockSkew = TimeSpan.Zero, + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + ValidIssuer = jwtIssuer, + ValidAudience = jwtIssuer, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey ?? "")) - }; - }); + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey ?? "")) + }; + }); //Jwt configuration builder.Services.AddSwaggerGen(options => @@ -71,7 +71,8 @@ }); builder.Services.AddCors(options => { - options.AddDefaultPolicy(builder => builder.WithOrigins("http://localhost:4200").AllowAnyMethod().AllowAnyHeader().AllowCredentials()); + options.AddDefaultPolicy(builder => + builder.WithOrigins("http://localhost:4200").AllowAnyMethod().AllowAnyHeader().AllowCredentials()); }); builder.Services.Configure(builder.Configuration.GetSection("MailSettings")); diff --git a/rag-2-backend/Services/EmailService.cs b/rag-2-backend/Services/EmailService.cs index 4665be5..079cef1 100644 --- a/rag-2-backend/Services/EmailService.cs +++ b/rag-2-backend/Services/EmailService.cs @@ -4,11 +4,11 @@ namespace rag_2_backend.Services; public class EmailService(EmailSendingUtil emailSendingUtil, IConfiguration config) { - public void SendConfirmationEmail(string to, string token) + public virtual void SendConfirmationEmail(string to, string token) { var address = config.GetValue("FrontendURLs:MailConfirmationURL") + token; var body = "Please confirm your email address by clicking this button: Confirm"; + address + "'>Confirm"; Task.Run(async () => await emailSendingUtil.SendMail(to, "Confirmation email", body)); diff --git a/rag-2-backend/Services/GameRecordService.cs b/rag-2-backend/Services/GameRecordService.cs index d510e7f..290ee69 100644 --- a/rag-2-backend/Services/GameRecordService.cs +++ b/rag-2-backend/Services/GameRecordService.cs @@ -22,9 +22,9 @@ public async Task> GetRecordsByGame(int gameId) public void AddGameRecord(RecordedGameRequest request, string email) { var user = context.Users.SingleOrDefault(u => u.Email == email) - ?? throw new KeyNotFoundException("User not found"); + ?? throw new KeyNotFoundException("User not found"); var game = context.Games.SingleOrDefault(g => g.Id == request.GameId) - ?? throw new KeyNotFoundException("Game not found"); + ?? throw new KeyNotFoundException("Game not found"); var recordedGame = new RecordedGame { diff --git a/rag-2-backend/Services/GameService.cs b/rag-2-backend/Services/GameService.cs index 899701c..e8be763 100644 --- a/rag-2-backend/Services/GameService.cs +++ b/rag-2-backend/Services/GameService.cs @@ -52,7 +52,7 @@ public void EditGame(GameRequest request, int id) public void RemoveGame(int id) { - var game = context.Games.SingleOrDefault(g=>g.Id == id) ?? throw new KeyNotFoundException("Game not found"); + var game = context.Games.SingleOrDefault(g => g.Id == id) ?? throw new KeyNotFoundException("Game not found"); var records = context.RecordedGames.Where(g => g.Game.Id == id).ToList(); if (records.Count > 0) throw new BadHttpRequestException("Game has records"); diff --git a/rag-2-backend/Test/GameRecordServiceTest.cs b/rag-2-backend/Test/GameRecordServiceTest.cs index 10972b0..3080ef9 100644 --- a/rag-2-backend/Test/GameRecordServiceTest.cs +++ b/rag-2-backend/Test/GameRecordServiceTest.cs @@ -17,17 +17,21 @@ public class GameRecordServiceTest private readonly Mock _contextMock = new( new DbContextOptionsBuilder().Options ); + private readonly GameRecordService _gameRecordService; + private readonly User _user = new("email@prz.edu.pl") { Id = 1, Password = "password", }; + private readonly Game _game = new() { Id = 1, Name = "Game1", }; + private readonly List _recordedGames = []; public GameRecordServiceTest() @@ -57,12 +61,14 @@ public GameRecordServiceTest() public async void GetRecordsByGameTest() { var actualRecords = await _gameRecordService.GetRecordsByGame(1); - List expectedRecords = [ - new() { + List expectedRecords = + [ + new() + { Id = 1, Value = "10", - GameResponse = new GameResponse { Id = 1 , Name = "Game1", GameType = GameType.EventGame }, - UserResponse = new UserResponse { Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher }, + GameResponse = new GameResponse { Id = 1, Name = "Game1", GameType = GameType.EventGame }, + UserResponse = new UserResponse { Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher }, }, ]; diff --git a/rag-2-backend/Test/GameServiceTest.cs b/rag-2-backend/Test/GameServiceTest.cs index 8af93b5..14c0998 100644 --- a/rag-2-backend/Test/GameServiceTest.cs +++ b/rag-2-backend/Test/GameServiceTest.cs @@ -18,7 +18,9 @@ public class GameServiceTest private readonly Mock _contextMock = new( new DbContextOptionsBuilder().Options ); + private readonly GameService _gameService; + private readonly List _games = [ new() { Id = 1, Name = "Game1", GameType = GameType.EventGame }, @@ -29,7 +31,8 @@ public GameServiceTest() { _gameService = new GameService(_contextMock.Object); _contextMock.Setup(c => c.Games).Returns(_games.AsQueryable().BuildMockDbSet().Object); - _contextMock.Setup(c => c.RecordedGames).Returns(new List().AsQueryable().BuildMockDbSet().Object); + _contextMock.Setup(c => c.RecordedGames) + .Returns(new List().AsQueryable().BuildMockDbSet().Object); } [Fact] @@ -37,7 +40,8 @@ public void ShouldGetAllGames() { var actualGames = _gameService.GetGames().Result; - Assert.Equal(JsonConvert.SerializeObject(_games.Select(GameMapper.Map)), JsonConvert.SerializeObject(actualGames)); + Assert.Equal(JsonConvert.SerializeObject(_games.Select(GameMapper.Map)), + JsonConvert.SerializeObject(actualGames)); } [Fact] @@ -51,7 +55,9 @@ public void ShouldAddGame() _gameService.AddGame(gameRequest); - _contextMock.Verify(c => c.Games.Add(It.Is(g => g.Name == gameRequest.Name && g.GameType == gameRequest.GameType)), Times.Once); + _contextMock.Verify( + c => c.Games.Add(It.Is(g => g.Name == gameRequest.Name && g.GameType == gameRequest.GameType)), + Times.Once); } [Fact] @@ -71,7 +77,7 @@ public void ShouldRemoveGame() { _gameService.RemoveGame(1); - _contextMock.Verify(c=> c.Games.Remove(It.Is(g=>g.Id == 1)), Times.Once); + _contextMock.Verify(c => c.Games.Remove(It.Is(g => g.Id == 1)), Times.Once); } [Fact] @@ -83,15 +89,18 @@ public void ShouldNotRemoveGameIfGameAlreadyExists() [Fact] public void ShouldThrowBadRequestIfGameHasRecords() { - List records = [new RecordedGame - { - Game = _games[0], - User = new User("email@prz.edu.pl") + List records = + [ + new RecordedGame { - Password = "pass" - }, - Value = "value" - }]; + Game = _games[0], + User = new User("email@prz.edu.pl") + { + Password = "pass" + }, + Value = "value" + } + ]; _contextMock.Setup(c => c.RecordedGames).Returns(records.AsQueryable().BuildMockDbSet().Object); Assert.Throws(() => _gameService.RemoveGame(1)); @@ -108,7 +117,9 @@ public void ShouldUpdateGame() _gameService.EditGame(gameRequest, 1); - _contextMock.Verify(c => c.Games.Update(It.Is(g => g.Name == gameRequest.Name && g.GameType == gameRequest.GameType)), Times.Once); + _contextMock.Verify( + c => c.Games.Update(It.Is(g => g.Name == gameRequest.Name && g.GameType == gameRequest.GameType)), + Times.Once); } [Fact] diff --git a/rag-2-backend/Test/UserServiceTest.cs b/rag-2-backend/Test/UserServiceTest.cs index ac96339..76d449b 100644 --- a/rag-2-backend/Test/UserServiceTest.cs +++ b/rag-2-backend/Test/UserServiceTest.cs @@ -1,8 +1,10 @@ using Microsoft.EntityFrameworkCore; using MockQueryable.Moq; using Moq; +using Newtonsoft.Json; using rag_2_backend.data; using rag_2_backend.DTO; +using rag_2_backend.Models; using rag_2_backend.Models.Entity; using rag_2_backend.Services; using rag_2_backend.Utils; @@ -15,48 +17,87 @@ public class UserServiceTest private readonly Mock _contextMock = new( new DbContextOptionsBuilder().Options ); + private readonly Mock _jwtUtilMock = new(null); - private readonly Mock _emailService = new(null); + private readonly Mock _emailService = new(null, null); private readonly UserService _userService; + private readonly User _user = new("email@prz.edu.pl") { Id = 1, Password = HashUtil.HashPassword("password"), }; + private readonly AccountConfirmationToken _token; + public UserServiceTest() { + _token = new AccountConfirmationToken + { + User = _user, + Expiration = DateTime.Now.AddDays(7), + Token = HashUtil.HashPassword("password"), + }; _userService = new UserService(_contextMock.Object, _jwtUtilMock.Object, _emailService.Object); _contextMock.Setup(c => c.Users).Returns(() => new List { _user } .AsQueryable().BuildMockDbSet().Object); - _jwtUtilMock.Setup(j=>j.GenerateToken(It.IsAny(), It.IsAny())).Verifiable(); + _contextMock.Setup(c => c.AccountConfirmationTokens) + .Returns(() => new List() { _token }.AsQueryable().BuildMockDbSet().Object); + _jwtUtilMock.Setup(j => j.GenerateToken(It.IsAny(), It.IsAny())).Verifiable(); + _emailService.Setup(e => e.SendConfirmationEmail(It.IsAny(), It.IsAny())).Verifiable(); } [Fact] public void ShouldRegisterUser() { _userService.RegisterUser(new UserRequest - {Email = "email1@prz.edu.pl", Password = "pass"} + { Email = "email1@prz.edu.pl", Password = "pass" } ); - _contextMock.Verify(c=>c.Users.Add(It.IsAny()), Times.Once); + _contextMock.Verify(c => c.Users.Add(It.IsAny()), Times.Once); + _contextMock.Verify(c => c.AccountConfirmationTokens.Add(It.IsAny()), Times.Once); + _emailService.Verify(e => e.SendConfirmationEmail(It.IsAny(), It.IsAny()), Times.Once); } - // [Fact] - // public void ShouldLoginUser() - // { - // Assert.ThrowsAsync( - // () => _userService.LoginUser("email@prz.edu.pl", "pass") - // ); //wrong password - // - // Assert.ThrowsAsync( - // () => _userService.LoginUser("email@prz.edu.pl", "password") - // ); //not confirmed - // - // _user.Confirmed = true; - // _userService.LoginUser("email@prz.edu.pl", "password"); - // _jwtUtilMock.Verify(j=>j.GenerateToken(It.IsAny(), It.IsAny()), Times.Once); - // } + [Fact] + public void ShouldConfirmAccount() + { + _userService.ConfirmAccount(_token.Token); + _contextMock.Verify(c => c.AccountConfirmationTokens.Remove(It.IsAny()), Times.Once); + Assert.Throws(() => _userService.ConfirmAccount("token")); //wrong token + _token.Expiration = DateTime.Now.AddDays(-7); + Assert.Throws(() => _userService.ConfirmAccount(_token.Token)); //invalid date + } + + [Fact] + public async void ShouldLoginUser() + { + await Assert.ThrowsAsync( + () => _userService.LoginUser("email@prz.edu.pl", "pass") + ); //wrong password + + await Assert.ThrowsAsync( + () => _userService.LoginUser("email@prz.edu.pl", "password") + ); //not confirmed + + _user.Confirmed = true; + await _userService.LoginUser("email@prz.edu.pl", "password"); + _jwtUtilMock.Verify(j => j.GenerateToken(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void ShouldGetMe() + { + var userResponse = new UserResponse() + { + Id = 1, + Email = "email@prz.edu.pl", + Role = Role.Teacher, + }; + + Assert.Equal(JsonConvert.SerializeObject(userResponse), + JsonConvert.SerializeObject(_userService.GetMe("email@prz.edu.pl").Result)); + } } \ No newline at end of file diff --git a/rag-2-backend/Test/UserTest.cs b/rag-2-backend/Test/UserTest.cs index 54ed88e..8b963f9 100644 --- a/rag-2-backend/Test/UserTest.cs +++ b/rag-2-backend/Test/UserTest.cs @@ -11,14 +11,14 @@ public void ShouldCreateUser() { Assert.Equal( Role.Student, - new User("index@stud.prz.edu.pl"){Password = "pass"}.Role + new User("index@stud.prz.edu.pl") { Password = "pass" }.Role ); Assert.Equal( Role.Teacher, - new User("index@prz.edu.pl"){Password = "pass"}.Role + new User("index@prz.edu.pl") { Password = "pass" }.Role ); Assert.Throws( - () => new User("index@gmail.com"){Password = "pass"} + () => new User("index@gmail.com") { Password = "pass" } ); } } \ No newline at end of file diff --git a/rag-2-backend/Utils/JwtUtil.cs b/rag-2-backend/Utils/JwtUtil.cs index f6ed4b4..256c838 100644 --- a/rag-2-backend/Utils/JwtUtil.cs +++ b/rag-2-backend/Utils/JwtUtil.cs @@ -30,5 +30,4 @@ public virtual string GenerateToken(string email, string role) return new JwtSecurityTokenHandler().WriteToken(token); } - -} +} \ No newline at end of file diff --git a/rag-2-backend/Utils/TokenGenerationUtil.cs b/rag-2-backend/Utils/TokenGenerationUtil.cs index 5609bff..bf6767c 100644 --- a/rag-2-backend/Utils/TokenGenerationUtil.cs +++ b/rag-2-backend/Utils/TokenGenerationUtil.cs @@ -13,6 +13,7 @@ public static string CreatePassword(int length) { res.Append(valid[rnd.Next(valid.Length)]); } + return res.ToString(); } } \ No newline at end of file From f09e0452d789c0c7b83d1c7ef4ede6e473b665f7 Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Fri, 16 Aug 2024 18:10:07 +0200 Subject: [PATCH 06/12] feat: #8 deleting unused tokens and resending email --- rag-2-backend/Controllers/GameController.cs | 2 +- rag-2-backend/Controllers/UserController.cs | 10 ++++- rag-2-backend/Program.cs | 1 + .../Services/BackgroundServiceImpl.cs | 38 +++++++++++++++++ rag-2-backend/Services/GameService.cs | 5 ++- rag-2-backend/Services/UserService.cs | 41 +++++++++++++++---- rag-2-backend/Test/GameServiceTest.cs | 22 +--------- rag-2-backend/Test/UserServiceTest.cs | 12 ++++++ 8 files changed, 98 insertions(+), 33 deletions(-) create mode 100644 rag-2-backend/Services/BackgroundServiceImpl.cs diff --git a/rag-2-backend/Controllers/GameController.cs b/rag-2-backend/Controllers/GameController.cs index bf79e5d..979f7e7 100644 --- a/rag-2-backend/Controllers/GameController.cs +++ b/rag-2-backend/Controllers/GameController.cs @@ -37,7 +37,7 @@ public void Edit([FromBody] [Required] GameRequest request, int id) } /// - /// (Admin) only if no record is connected + /// (Admin) /// [HttpDelete("{id:int}")] [Authorize(Roles = "Admin")] diff --git a/rag-2-backend/Controllers/UserController.cs b/rag-2-backend/Controllers/UserController.cs index 12083a6..3df73cb 100644 --- a/rag-2-backend/Controllers/UserController.cs +++ b/rag-2-backend/Controllers/UserController.cs @@ -31,12 +31,20 @@ public void Logout() userService.LogoutUser(email); } - [HttpPost("auth/confirm")] + [HttpPost("auth/resend-confirmation-email")] + public void ResendConfirmationEmail([Required] string email) + { + userService.ResendConfirmationEmail(email); + } + + [HttpPost("auth/confirm-account")] public void ConfirmAccount([Required] string token) { userService.ConfirmAccount(token); } + + /// /// (Autneticated) /// diff --git a/rag-2-backend/Program.cs b/rag-2-backend/Program.cs index 3d2145b..07f6d1d 100644 --- a/rag-2-backend/Program.cs +++ b/rag-2-backend/Program.cs @@ -84,6 +84,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddHostedService(); var app = builder.Build(); diff --git a/rag-2-backend/Services/BackgroundServiceImpl.cs b/rag-2-backend/Services/BackgroundServiceImpl.cs new file mode 100644 index 0000000..8c6c67b --- /dev/null +++ b/rag-2-backend/Services/BackgroundServiceImpl.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using rag_2_backend.data; +using rag_2_backend.Models.Entity; + +namespace rag_2_backend.Services; + +public class BackgroundServiceImpl(IServiceProvider serviceProvider) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + CheckUnusedAccountTokens(); + + await Task.Delay(TimeSpan.FromDays(1), cancellationToken); + } + } + + private void CheckUnusedAccountTokens() + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var unconfirmedUsers = new List(); + var unusedTokens = dbContext.AccountConfirmationTokens + .Include(t=>t.User) + .Where(t=>t.Expiration < DateTime.Now).ToList(); + + foreach (var users in unusedTokens.Select(token => new List(dbContext.Users.Where(u => u.Email == token.User.Email && !u.Confirmed)))) + unconfirmedUsers.AddRange(users); + + dbContext.Users.RemoveRange(unconfirmedUsers); + dbContext.AccountConfirmationTokens.RemoveRange(unusedTokens); + dbContext.SaveChanges(); + + Console.WriteLine("Deleted " + unconfirmedUsers.Count + " unconfirmed accounts"); + } +} \ No newline at end of file diff --git a/rag-2-backend/Services/GameService.cs b/rag-2-backend/Services/GameService.cs index e8be763..8d5a43a 100644 --- a/rag-2-backend/Services/GameService.cs +++ b/rag-2-backend/Services/GameService.cs @@ -55,7 +55,10 @@ public void RemoveGame(int id) var game = context.Games.SingleOrDefault(g => g.Id == id) ?? throw new KeyNotFoundException("Game not found"); var records = context.RecordedGames.Where(g => g.Game.Id == id).ToList(); - if (records.Count > 0) throw new BadHttpRequestException("Game has records"); + foreach(var record in records) + { + context.RecordedGames.Remove(record); + } context.Games.Remove(game); context.SaveChanges(); diff --git a/rag-2-backend/Services/UserService.cs b/rag-2-backend/Services/UserService.cs index b03bd0e..c1696df 100644 --- a/rag-2-backend/Services/UserService.cs +++ b/rag-2-backend/Services/UserService.cs @@ -18,18 +18,27 @@ public void RegisterUser(UserRequest userRequest) { Password = HashUtil.HashPassword(userRequest.Password) }; - var token = new AccountConfirmationToken() - { - Token = TokenGenerationUtil.CreatePassword(15), - User = user, - Expiration = DateTime.Now.AddDays(7) - }; - context.Users.Add(user); - context.AccountConfirmationTokens.Add(token); + + GenerateAccountTokenAndSendConfirmationMail(user); + context.SaveChanges(); + } - emailService.SendConfirmationEmail(user.Email, token.Token); + public void ResendConfirmationEmail(string email) + { + var user = context.Users.SingleOrDefault(u => u.Email == email) ?? + throw new KeyNotFoundException("User not found"); + + if(user.Confirmed) throw new BadHttpRequestException("User is already confirmed"); + + context.AccountConfirmationTokens.RemoveRange( + context.AccountConfirmationTokens.Where(a=>a.User.Email == user.Email) + ); + + GenerateAccountTokenAndSendConfirmationMail(user); + + context.SaveChanges(); } public void ConfirmAccount(string tokenValue) @@ -73,4 +82,18 @@ public void LogoutUser(string email) { // Redis queueing of blacklisted tokens } + + // + + private void GenerateAccountTokenAndSendConfirmationMail(User user) + { + var token = new AccountConfirmationToken + { + Token = TokenGenerationUtil.CreatePassword(15), + User = user, + Expiration = DateTime.Now.AddDays(7) + }; + context.AccountConfirmationTokens.Add(token); + emailService.SendConfirmationEmail(user.Email, token.Token); + } } \ No newline at end of file diff --git a/rag-2-backend/Test/GameServiceTest.cs b/rag-2-backend/Test/GameServiceTest.cs index 14c0998..ea2e9bc 100644 --- a/rag-2-backend/Test/GameServiceTest.cs +++ b/rag-2-backend/Test/GameServiceTest.cs @@ -81,31 +81,11 @@ public void ShouldRemoveGame() } [Fact] - public void ShouldNotRemoveGameIfGameAlreadyExists() + public void ShouldNotRemoveGameIfGameNotExists() { Assert.Throws(() => _gameService.RemoveGame(4)); } - [Fact] - public void ShouldThrowBadRequestIfGameHasRecords() - { - List records = - [ - new RecordedGame - { - Game = _games[0], - User = new User("email@prz.edu.pl") - { - Password = "pass" - }, - Value = "value" - } - ]; - _contextMock.Setup(c => c.RecordedGames).Returns(records.AsQueryable().BuildMockDbSet().Object); - - Assert.Throws(() => _gameService.RemoveGame(1)); - } - [Fact] public void ShouldUpdateGame() { diff --git a/rag-2-backend/Test/UserServiceTest.cs b/rag-2-backend/Test/UserServiceTest.cs index 76d449b..56fc11f 100644 --- a/rag-2-backend/Test/UserServiceTest.cs +++ b/rag-2-backend/Test/UserServiceTest.cs @@ -60,6 +60,18 @@ public void ShouldRegisterUser() _emailService.Verify(e => e.SendConfirmationEmail(It.IsAny(), It.IsAny()), Times.Once); } + [Fact] + public void ShouldResendConfirmationMail() + { + _userService.ResendConfirmationEmail("email@prz.edu.pl"); + + _contextMock.Verify(c => c.AccountConfirmationTokens.Add(It.IsAny()), Times.Once); + _emailService.Verify(e => e.SendConfirmationEmail(It.IsAny(), It.IsAny()), Times.Once); + + _user.Confirmed = true; + Assert.Throws(()=>_userService.ResendConfirmationEmail("email@prz.edu.pl")); + } + [Fact] public void ShouldConfirmAccount() { From 8b5620d293aa8387ac6e26611cbda3041482d35d Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Fri, 16 Aug 2024 19:20:37 +0200 Subject: [PATCH 07/12] feat: #8 jwt logout logic --- rag-2-backend/Controllers/UserController.cs | 14 +- rag-2-backend/Data/DatabaseContext.cs | 3 +- ...20240816164555_BlacklistedJwts.Designer.cs | 173 ++++++++++++++++++ .../20240816164555_BlacklistedJwts.cs | 34 ++++ .../DatabaseContextModelSnapshot.cs | 14 ++ rag-2-backend/Models/Entity/BlacklistedJwt.cs | 11 ++ rag-2-backend/Program.cs | 18 +- .../Services/BackgroundServiceImpl.cs | 20 +- rag-2-backend/Services/GameService.cs | 2 +- rag-2-backend/Services/UserService.cs | 29 ++- rag-2-backend/Test/UserServiceTest.cs | 23 ++- rag-2-backend/Utils/JwtUtil.cs | 9 +- 12 files changed, 324 insertions(+), 26 deletions(-) create mode 100644 rag-2-backend/Migrations/20240816164555_BlacklistedJwts.Designer.cs create mode 100644 rag-2-backend/Migrations/20240816164555_BlacklistedJwts.cs create mode 100644 rag-2-backend/Models/Entity/BlacklistedJwt.cs diff --git a/rag-2-backend/Controllers/UserController.cs b/rag-2-backend/Controllers/UserController.cs index 3df73cb..20f8223 100644 --- a/rag-2-backend/Controllers/UserController.cs +++ b/rag-2-backend/Controllers/UserController.cs @@ -1,9 +1,11 @@ using System.ComponentModel.DataAnnotations; +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using rag_2_backend.DTO; using rag_2_backend.Services; +using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; namespace rag_2_backend.controllers; @@ -24,11 +26,13 @@ public async Task Login([FromBody] [Required] UserRequest loginRequest) } [HttpPost("auth/logout")] + [Authorize] public void Logout() { - var email = (User.FindFirst(ClaimTypes.Email)?.Value) ?? throw new UnauthorizedAccessException("Unauthorized"); + var header = HttpContext.Request.Headers.Authorization.FirstOrDefault() ?? + throw new UnauthorizedAccessException("Unauthorized"); - userService.LogoutUser(email); + userService.LogoutUser(header); } [HttpPost("auth/resend-confirmation-email")] @@ -43,16 +47,14 @@ public void ConfirmAccount([Required] string token) userService.ConfirmAccount(token); } - - /// - /// (Autneticated) + /// (Auth) /// [HttpGet("me")] [Authorize] public async Task Me() { - var email = (User.FindFirst(ClaimTypes.Email)?.Value) ?? throw new UnauthorizedAccessException("Unauthorized"); + var email = User.FindFirst(ClaimTypes.Email)?.Value ?? throw new UnauthorizedAccessException("Unauthorized"); return await userService.GetMe(email); } diff --git a/rag-2-backend/Data/DatabaseContext.cs b/rag-2-backend/Data/DatabaseContext.cs index c58e80c..81f3e2a 100644 --- a/rag-2-backend/Data/DatabaseContext.cs +++ b/rag-2-backend/Data/DatabaseContext.cs @@ -7,9 +7,8 @@ namespace rag_2_backend.data; public class DatabaseContext(DbContextOptions options) : DbContext(options) { public virtual required DbSet Games { get; init; } - public virtual required DbSet RecordedGames { get; init; } - public virtual required DbSet Users { get; init; } public virtual required DbSet AccountConfirmationTokens { get; init; } + public virtual required DbSet BlacklistedJwts { get; init; } } \ No newline at end of file diff --git a/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.Designer.cs b/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.Designer.cs new file mode 100644 index 0000000..2c26c4f --- /dev/null +++ b/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.Designer.cs @@ -0,0 +1,173 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using rag_2_backend.data; + +#nullable disable + +namespace rag_2_backend.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20240816164555_BlacklistedJwts")] + partial class BlacklistedJwts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("account_confirmation_token"); + }); + + modelBuilder.Entity("rag_2_backend.Models.Entity.BlacklistedJwt", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Token"); + + b.ToTable("blacklisted_jwt"); + }); + + modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Confirmed") + .HasColumnType("boolean"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("users"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("games"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId"); + + b.ToTable("recorded_games"); + }); + + modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => + { + b.HasOne("rag_2_backend.Models.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => + { + b.HasOne("rag_2_backend.models.entity.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("rag_2_backend.Models.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.cs b/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.cs new file mode 100644 index 0000000..8bf1205 --- /dev/null +++ b/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace rag_2_backend.Migrations +{ + /// + public partial class BlacklistedJwts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "blacklisted_jwt", + columns: table => new + { + Token = table.Column(type: "character varying(100)", maxLength: 500, nullable: false), + Expiration = table.Column(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_blacklisted_jwt", x => x.Token); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "blacklisted_jwt"); + } + } +} diff --git a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs index 562be9b..efaa809 100644 --- a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs +++ b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs @@ -41,6 +41,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("account_confirmation_token"); }); + modelBuilder.Entity("rag_2_backend.Models.Entity.BlacklistedJwt", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Token"); + + b.ToTable("blacklisted_jwt"); + }); + modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => { b.Property("Id") diff --git a/rag-2-backend/Models/Entity/BlacklistedJwt.cs b/rag-2-backend/Models/Entity/BlacklistedJwt.cs new file mode 100644 index 0000000..d59cce8 --- /dev/null +++ b/rag-2-backend/Models/Entity/BlacklistedJwt.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace rag_2_backend.Models.Entity; + +[Table("blacklisted_jwt")] +public class BlacklistedJwt +{ + [Key] [MaxLength(500)] public required string Token { get; init; } + public required DateTime Expiration { get; init; } +} \ No newline at end of file diff --git a/rag-2-backend/Program.cs b/rag-2-backend/Program.cs index 07f6d1d..2e9009e 100644 --- a/rag-2-backend/Program.cs +++ b/rag-2-backend/Program.cs @@ -27,10 +27,26 @@ ValidAudience = jwtIssuer, ValidateLifetime = true, ClockSkew = TimeSpan.Zero, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey ?? "")) }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = async context => + { + var tokenBlacklistService = context.HttpContext.RequestServices.GetRequiredService(); + var header = context.HttpContext.Request.Headers.Authorization.FirstOrDefault(); + if (header == null) return; + var token = header["Bearer ".Length..].Trim(); + + if (!string.IsNullOrEmpty(token) && await tokenBlacklistService.IsTokenBlacklistedAsync(token)) + { + throw new UnauthorizedAccessException("Token is not valid"); + } + } + }; }); + //Jwt configuration builder.Services.AddSwaggerGen(options => diff --git a/rag-2-backend/Services/BackgroundServiceImpl.cs b/rag-2-backend/Services/BackgroundServiceImpl.cs index 8c6c67b..ba87cce 100644 --- a/rag-2-backend/Services/BackgroundServiceImpl.cs +++ b/rag-2-backend/Services/BackgroundServiceImpl.cs @@ -11,6 +11,7 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) while (!cancellationToken.IsCancellationRequested) { CheckUnusedAccountTokens(); + DeleteUnusedBlacklistedJwts(); await Task.Delay(TimeSpan.FromDays(1), cancellationToken); } @@ -23,10 +24,11 @@ private void CheckUnusedAccountTokens() var unconfirmedUsers = new List(); var unusedTokens = dbContext.AccountConfirmationTokens - .Include(t=>t.User) - .Where(t=>t.Expiration < DateTime.Now).ToList(); + .Include(t => t.User) + .Where(t => t.Expiration < DateTime.Now).ToList(); - foreach (var users in unusedTokens.Select(token => new List(dbContext.Users.Where(u => u.Email == token.User.Email && !u.Confirmed)))) + foreach (var users in unusedTokens.Select(token => + new List(dbContext.Users.Where(u => u.Email == token.User.Email && !u.Confirmed)))) unconfirmedUsers.AddRange(users); dbContext.Users.RemoveRange(unconfirmedUsers); @@ -35,4 +37,16 @@ private void CheckUnusedAccountTokens() Console.WriteLine("Deleted " + unconfirmedUsers.Count + " unconfirmed accounts"); } + + private void DeleteUnusedBlacklistedJwts() + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var unusedTokens = dbContext.BlacklistedJwts.Where(b => b.Expiration < DateTime.Now).ToList(); + dbContext.BlacklistedJwts.RemoveRange(unusedTokens); + dbContext.SaveChanges(); + + Console.WriteLine("Deleted" + unusedTokens.Count + " blacklisted jwts"); + } } \ No newline at end of file diff --git a/rag-2-backend/Services/GameService.cs b/rag-2-backend/Services/GameService.cs index 8d5a43a..8f9f07d 100644 --- a/rag-2-backend/Services/GameService.cs +++ b/rag-2-backend/Services/GameService.cs @@ -55,7 +55,7 @@ public void RemoveGame(int id) var game = context.Games.SingleOrDefault(g => g.Id == id) ?? throw new KeyNotFoundException("Game not found"); var records = context.RecordedGames.Where(g => g.Game.Id == id).ToList(); - foreach(var record in records) + foreach (var record in records) { context.RecordedGames.Remove(record); } diff --git a/rag-2-backend/Services/UserService.cs b/rag-2-backend/Services/UserService.cs index c1696df..d683e21 100644 --- a/rag-2-backend/Services/UserService.cs +++ b/rag-2-backend/Services/UserService.cs @@ -1,3 +1,4 @@ +using System.IdentityModel.Tokens.Jwt; using Microsoft.EntityFrameworkCore; using rag_2_backend.data; using rag_2_backend.DTO; @@ -7,7 +8,11 @@ namespace rag_2_backend.Services; -public class UserService(DatabaseContext context, JwtUtil jwtUtil, EmailService emailService) +public class UserService( + DatabaseContext context, + JwtUtil jwtUtil, + EmailService emailService, + JwtSecurityTokenHandler jwtSecurityTokenHandler) { public void RegisterUser(UserRequest userRequest) { @@ -30,12 +35,12 @@ public void ResendConfirmationEmail(string email) var user = context.Users.SingleOrDefault(u => u.Email == email) ?? throw new KeyNotFoundException("User not found"); - if(user.Confirmed) throw new BadHttpRequestException("User is already confirmed"); - + if (user.Confirmed) throw new BadHttpRequestException("User is already confirmed"); + context.AccountConfirmationTokens.RemoveRange( - context.AccountConfirmationTokens.Where(a=>a.User.Email == user.Email) + context.AccountConfirmationTokens.Where(a => a.User.Email == user.Email) ); - + GenerateAccountTokenAndSendConfirmationMail(user); context.SaveChanges(); @@ -78,13 +83,21 @@ public async Task GetMe(string email) return UserMapper.Map(user); } - public void LogoutUser(string email) + public void LogoutUser(string header) { - // Redis queueing of blacklisted tokens + var tokenValue = header["Bearer ".Length..].Trim(); + + var jwtToken = jwtSecurityTokenHandler.ReadToken(tokenValue) as JwtSecurityToken ?? + throw new UnauthorizedAccessException("Unauthorized"); + + var expiryDate = jwtToken.ValidTo; + context.BlacklistedJwts.Add(new BlacklistedJwt { Token = tokenValue, Expiration = expiryDate }); + + context.SaveChanges(); } // - + private void GenerateAccountTokenAndSendConfirmationMail(User user) { var token = new AccountConfirmationToken diff --git a/rag-2-backend/Test/UserServiceTest.cs b/rag-2-backend/Test/UserServiceTest.cs index 56fc11f..dcb05bb 100644 --- a/rag-2-backend/Test/UserServiceTest.cs +++ b/rag-2-backend/Test/UserServiceTest.cs @@ -1,9 +1,11 @@ +using System.IdentityModel.Tokens.Jwt; using Microsoft.EntityFrameworkCore; using MockQueryable.Moq; using Moq; using Newtonsoft.Json; using rag_2_backend.data; using rag_2_backend.DTO; +using rag_2_backend.Migrations; using rag_2_backend.Models; using rag_2_backend.Models.Entity; using rag_2_backend.Services; @@ -18,7 +20,8 @@ public class UserServiceTest new DbContextOptionsBuilder().Options ); - private readonly Mock _jwtUtilMock = new(null); + private readonly Mock _jwtUtilMock = new(null, null); + private readonly Mock _jwtSecurityTokenHandlerMock = new(); private readonly Mock _emailService = new(null, null); private readonly UserService _userService; @@ -38,14 +41,18 @@ public UserServiceTest() Expiration = DateTime.Now.AddDays(7), Token = HashUtil.HashPassword("password"), }; - _userService = new UserService(_contextMock.Object, _jwtUtilMock.Object, _emailService.Object); + _userService = new UserService(_contextMock.Object, _jwtUtilMock.Object, _emailService.Object, + _jwtSecurityTokenHandlerMock.Object); _contextMock.Setup(c => c.Users).Returns(() => new List { _user } .AsQueryable().BuildMockDbSet().Object); _contextMock.Setup(c => c.AccountConfirmationTokens) - .Returns(() => new List() { _token }.AsQueryable().BuildMockDbSet().Object); + .Returns(() => new List { _token }.AsQueryable().BuildMockDbSet().Object); + _contextMock.Setup(c => c.BlacklistedJwts) + .Returns(() => new List().AsQueryable().BuildMockDbSet().Object); _jwtUtilMock.Setup(j => j.GenerateToken(It.IsAny(), It.IsAny())).Verifiable(); _emailService.Setup(e => e.SendConfirmationEmail(It.IsAny(), It.IsAny())).Verifiable(); + _jwtSecurityTokenHandlerMock.Setup(e => e.ReadToken(It.IsAny())).Returns(() => new JwtSecurityToken()); } [Fact] @@ -69,7 +76,7 @@ public void ShouldResendConfirmationMail() _emailService.Verify(e => e.SendConfirmationEmail(It.IsAny(), It.IsAny()), Times.Once); _user.Confirmed = true; - Assert.Throws(()=>_userService.ResendConfirmationEmail("email@prz.edu.pl")); + Assert.Throws(() => _userService.ResendConfirmationEmail("email@prz.edu.pl")); } [Fact] @@ -99,6 +106,14 @@ await Assert.ThrowsAsync( _jwtUtilMock.Verify(j => j.GenerateToken(It.IsAny(), It.IsAny()), Times.Once); } + [Fact] + public void ShouldLogoutUser() + { + _userService.LogoutUser("Bearer header"); + + _contextMock.Verify(c => c.BlacklistedJwts.Add(It.IsAny()), Times.Once); + } + [Fact] public void ShouldGetMe() { diff --git a/rag-2-backend/Utils/JwtUtil.cs b/rag-2-backend/Utils/JwtUtil.cs index 256c838..8e524f7 100644 --- a/rag-2-backend/Utils/JwtUtil.cs +++ b/rag-2-backend/Utils/JwtUtil.cs @@ -1,11 +1,13 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; +using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; +using rag_2_backend.data; namespace rag_2_backend.Utils; -public class JwtUtil(IConfiguration config) +public class JwtUtil(IConfiguration config, DatabaseContext context) { public virtual string GenerateToken(string email, string role) @@ -30,4 +32,9 @@ public virtual string GenerateToken(string email, string role) return new JwtSecurityTokenHandler().WriteToken(token); } + + public async Task IsTokenBlacklistedAsync(string token) + { + return await context.BlacklistedJwts.AnyAsync(bt => bt.Token == token); + } } \ No newline at end of file From 195b4ee737e3a7c14bf387e27b8ed371a92ad535 Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Fri, 16 Aug 2024 20:33:55 +0200 Subject: [PATCH 08/12] feat: #8 user study year with validation --- rag-2-backend/Controllers/UserController.cs | 2 +- rag-2-backend/DTO/Mapper/UserMapper.cs | 4 +- rag-2-backend/DTO/UserLoginRequest.cs | 7 + rag-2-backend/DTO/UserRequest.cs | 2 + rag-2-backend/DTO/UserResponse.cs | 2 + .../20240816183043_UserStudyYear.Designer.cs | 179 ++++++++++++++++++ .../20240816183043_UserStudyYear.cs | 60 ++++++ .../DatabaseContextModelSnapshot.cs | 10 +- rag-2-backend/Models/Entity/User.cs | 6 +- rag-2-backend/Program.cs | 2 + rag-2-backend/Services/UserService.cs | 10 +- rag-2-backend/Test/GameRecordServiceTest.cs | 4 +- rag-2-backend/Test/UserServiceTest.cs | 6 +- rag-2-backend/Test/UserTest.cs | 6 +- 14 files changed, 288 insertions(+), 12 deletions(-) create mode 100644 rag-2-backend/DTO/UserLoginRequest.cs create mode 100644 rag-2-backend/Migrations/20240816183043_UserStudyYear.Designer.cs create mode 100644 rag-2-backend/Migrations/20240816183043_UserStudyYear.cs diff --git a/rag-2-backend/Controllers/UserController.cs b/rag-2-backend/Controllers/UserController.cs index 20f8223..3e8b456 100644 --- a/rag-2-backend/Controllers/UserController.cs +++ b/rag-2-backend/Controllers/UserController.cs @@ -20,7 +20,7 @@ public void Register([FromBody] [Required] UserRequest userRequest) } [HttpPost("auth/login")] - public async Task Login([FromBody] [Required] UserRequest loginRequest) + public async Task Login([FromBody] [Required] UserLoginRequest loginRequest) { return await userService.LoginUser(loginRequest.Email, loginRequest.Password); } diff --git a/rag-2-backend/DTO/Mapper/UserMapper.cs b/rag-2-backend/DTO/Mapper/UserMapper.cs index 6ac4621..c92d432 100644 --- a/rag-2-backend/DTO/Mapper/UserMapper.cs +++ b/rag-2-backend/DTO/Mapper/UserMapper.cs @@ -11,7 +11,9 @@ public static UserResponse Map(User user) { Id = user.Id, Email = user.Email, - Role = user.Role + Role = user.Role, + StudyCycleYearA = user.StudyCycleYearA, + StudyCycleYearB = user.StudyCycleYearB, }; } } \ No newline at end of file diff --git a/rag-2-backend/DTO/UserLoginRequest.cs b/rag-2-backend/DTO/UserLoginRequest.cs new file mode 100644 index 0000000..aeb4ad2 --- /dev/null +++ b/rag-2-backend/DTO/UserLoginRequest.cs @@ -0,0 +1,7 @@ +namespace rag_2_backend.DTO; + +public class UserLoginRequest +{ + public required string Email { get; set; } + public required string Password { get; set; } +} \ No newline at end of file diff --git a/rag-2-backend/DTO/UserRequest.cs b/rag-2-backend/DTO/UserRequest.cs index f0d4048..bb63620 100644 --- a/rag-2-backend/DTO/UserRequest.cs +++ b/rag-2-backend/DTO/UserRequest.cs @@ -4,4 +4,6 @@ public class UserRequest { public required string Email { get; set; } public required string Password { get; set; } + public int StudyCycleYearA { get; init; } + public int StudyCycleYearB { get; init; } } \ No newline at end of file diff --git a/rag-2-backend/DTO/UserResponse.cs b/rag-2-backend/DTO/UserResponse.cs index 7519663..78567d5 100644 --- a/rag-2-backend/DTO/UserResponse.cs +++ b/rag-2-backend/DTO/UserResponse.cs @@ -9,4 +9,6 @@ public class UserResponse [Key] public required int Id { get; set; } public required string Email { get; set; } public Role Role { get; set; } + public required int StudyCycleYearA { get; set; } + public required int StudyCycleYearB { get; set; } } \ No newline at end of file diff --git a/rag-2-backend/Migrations/20240816183043_UserStudyYear.Designer.cs b/rag-2-backend/Migrations/20240816183043_UserStudyYear.Designer.cs new file mode 100644 index 0000000..046f417 --- /dev/null +++ b/rag-2-backend/Migrations/20240816183043_UserStudyYear.Designer.cs @@ -0,0 +1,179 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using rag_2_backend.data; + +#nullable disable + +namespace rag_2_backend.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20240816183043_UserStudyYear")] + partial class UserStudyYear + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("account_confirmation_token"); + }); + + modelBuilder.Entity("rag_2_backend.Models.Entity.BlacklistedJwt", b => + { + b.Property("Token") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Token"); + + b.ToTable("blacklisted_jwt"); + }); + + modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Confirmed") + .HasColumnType("boolean"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("StudyCycleYearA") + .HasColumnType("integer"); + + b.Property("StudyCycleYearB") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("users"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("games"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId"); + + b.ToTable("recorded_games"); + }); + + modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => + { + b.HasOne("rag_2_backend.Models.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => + { + b.HasOne("rag_2_backend.models.entity.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("rag_2_backend.Models.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/rag-2-backend/Migrations/20240816183043_UserStudyYear.cs b/rag-2-backend/Migrations/20240816183043_UserStudyYear.cs new file mode 100644 index 0000000..b067c95 --- /dev/null +++ b/rag-2-backend/Migrations/20240816183043_UserStudyYear.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace rag_2_backend.Migrations +{ + /// + public partial class UserStudyYear : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "StudyCycleYearA", + table: "users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "StudyCycleYearB", + table: "users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AlterColumn( + name: "Token", + table: "blacklisted_jwt", + type: "character varying(500)", + maxLength: 500, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "StudyCycleYearA", + table: "users"); + + migrationBuilder.DropColumn( + name: "StudyCycleYearB", + table: "users"); + + migrationBuilder.AlterColumn( + name: "Token", + table: "blacklisted_jwt", + type: "character varying(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(500)", + oldMaxLength: 500); + } + } +} diff --git a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs index efaa809..6c77ff9 100644 --- a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs +++ b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs @@ -44,8 +44,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("rag_2_backend.Models.Entity.BlacklistedJwt", b => { b.Property("Token") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasMaxLength(500) + .HasColumnType("character varying(500)"); b.Property("Expiration") .HasColumnType("timestamp without time zone"); @@ -79,6 +79,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Role") .HasColumnType("integer"); + b.Property("StudyCycleYearA") + .HasColumnType("integer"); + + b.Property("StudyCycleYearB") + .HasColumnType("integer"); + b.HasKey("Id"); b.ToTable("users"); diff --git a/rag-2-backend/Models/Entity/User.cs b/rag-2-backend/Models/Entity/User.cs index 104156d..621aee6 100644 --- a/rag-2-backend/Models/Entity/User.cs +++ b/rag-2-backend/Models/Entity/User.cs @@ -8,10 +8,12 @@ namespace rag_2_backend.Models.Entity; public class User { [Key] public int Id { get; init; } - [MaxLength(100)] public string Email { get; set; } = ""; + [MaxLength(100)] public string Email { get; init; } = ""; [MaxLength(100)] public required string Password { get; init; } public Role Role { get; set; } - public bool Confirmed { get; set; } = false; + public bool Confirmed { get; set; } + public int StudyCycleYearA { get; init; } + public int StudyCycleYearB { get; init; } public User() //for ef { diff --git a/rag-2-backend/Program.cs b/rag-2-backend/Program.cs index 2e9009e..cb48422 100644 --- a/rag-2-backend/Program.cs +++ b/rag-2-backend/Program.cs @@ -1,3 +1,4 @@ +using System.IdentityModel.Tokens.Jwt; using Microsoft.EntityFrameworkCore; using System.Text.Json.Serialization; using rag_2_backend.data; @@ -101,6 +102,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/rag-2-backend/Services/UserService.cs b/rag-2-backend/Services/UserService.cs index d683e21..628420f 100644 --- a/rag-2-backend/Services/UserService.cs +++ b/rag-2-backend/Services/UserService.cs @@ -3,6 +3,7 @@ using rag_2_backend.data; using rag_2_backend.DTO; using rag_2_backend.DTO.Mapper; +using rag_2_backend.Models; using rag_2_backend.Models.Entity; using rag_2_backend.Utils; @@ -21,8 +22,15 @@ public void RegisterUser(UserRequest userRequest) User user = new(userRequest.Email) { - Password = HashUtil.HashPassword(userRequest.Password) + Password = HashUtil.HashPassword(userRequest.Password), + StudyCycleYearA = userRequest.StudyCycleYearA, + StudyCycleYearB = userRequest.StudyCycleYearB, }; + if (user.Role == Role.Student) + { + if(userRequest.StudyCycleYearA == 0 || userRequest.StudyCycleYearB == 0 || userRequest.StudyCycleYearB - userRequest.StudyCycleYearA != 1) + throw new BadHttpRequestException("Wrong study cycle year"); + } context.Users.Add(user); GenerateAccountTokenAndSendConfirmationMail(user); diff --git a/rag-2-backend/Test/GameRecordServiceTest.cs b/rag-2-backend/Test/GameRecordServiceTest.cs index 3080ef9..8f4e42a 100644 --- a/rag-2-backend/Test/GameRecordServiceTest.cs +++ b/rag-2-backend/Test/GameRecordServiceTest.cs @@ -24,6 +24,8 @@ public class GameRecordServiceTest { Id = 1, Password = "password", + StudyCycleYearA = 2022, + StudyCycleYearB = 2023 }; private readonly Game _game = new() @@ -68,7 +70,7 @@ public async void GetRecordsByGameTest() Id = 1, Value = "10", GameResponse = new GameResponse { Id = 1, Name = "Game1", GameType = GameType.EventGame }, - UserResponse = new UserResponse { Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher }, + UserResponse = new UserResponse { Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher, StudyCycleYearA = 2022, StudyCycleYearB = 2023}, }, ]; diff --git a/rag-2-backend/Test/UserServiceTest.cs b/rag-2-backend/Test/UserServiceTest.cs index dcb05bb..3fc00af 100644 --- a/rag-2-backend/Test/UserServiceTest.cs +++ b/rag-2-backend/Test/UserServiceTest.cs @@ -29,6 +29,8 @@ public class UserServiceTest { Id = 1, Password = HashUtil.HashPassword("password"), + StudyCycleYearA = 2022, + StudyCycleYearB = 2023 }; private readonly AccountConfirmationToken _token; @@ -59,7 +61,7 @@ public UserServiceTest() public void ShouldRegisterUser() { _userService.RegisterUser(new UserRequest - { Email = "email1@prz.edu.pl", Password = "pass" } + { Email = "email1@prz.edu.pl", Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023} ); _contextMock.Verify(c => c.Users.Add(It.IsAny()), Times.Once); @@ -122,6 +124,8 @@ public void ShouldGetMe() Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher, + StudyCycleYearA = 2022, + StudyCycleYearB = 2023 }; Assert.Equal(JsonConvert.SerializeObject(userResponse), diff --git a/rag-2-backend/Test/UserTest.cs b/rag-2-backend/Test/UserTest.cs index 8b963f9..59bc970 100644 --- a/rag-2-backend/Test/UserTest.cs +++ b/rag-2-backend/Test/UserTest.cs @@ -11,14 +11,14 @@ public void ShouldCreateUser() { Assert.Equal( Role.Student, - new User("index@stud.prz.edu.pl") { Password = "pass" }.Role + new User("index@stud.prz.edu.pl") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023}.Role ); Assert.Equal( Role.Teacher, - new User("index@prz.edu.pl") { Password = "pass" }.Role + new User("index@prz.edu.pl") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023}.Role ); Assert.Throws( - () => new User("index@gmail.com") { Password = "pass" } + () => new User("index@gmail.com") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023} ); } } \ No newline at end of file From 4568504bf6f3749cae2ef4e1ba822b1bef45e5d7 Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Fri, 16 Aug 2024 20:33:55 +0200 Subject: [PATCH 09/12] feat: #8 user study year with validation --- rag-2-backend/Controllers/UserController.cs | 2 +- rag-2-backend/DTO/Mapper/UserMapper.cs | 4 +- rag-2-backend/DTO/UserLoginRequest.cs | 7 + rag-2-backend/DTO/UserRequest.cs | 2 + rag-2-backend/DTO/UserResponse.cs | 2 + .../20240816183043_UserStudyYear.Designer.cs | 179 ++++++++++++++++++ .../20240816183043_UserStudyYear.cs | 60 ++++++ .../DatabaseContextModelSnapshot.cs | 10 +- rag-2-backend/Models/Entity/User.cs | 6 +- rag-2-backend/Program.cs | 2 + rag-2-backend/Services/UserService.cs | 10 +- rag-2-backend/Test/GameRecordServiceTest.cs | 4 +- rag-2-backend/Test/UserServiceTest.cs | 12 +- rag-2-backend/Test/UserTest.cs | 6 +- 14 files changed, 294 insertions(+), 12 deletions(-) create mode 100644 rag-2-backend/DTO/UserLoginRequest.cs create mode 100644 rag-2-backend/Migrations/20240816183043_UserStudyYear.Designer.cs create mode 100644 rag-2-backend/Migrations/20240816183043_UserStudyYear.cs diff --git a/rag-2-backend/Controllers/UserController.cs b/rag-2-backend/Controllers/UserController.cs index 20f8223..3e8b456 100644 --- a/rag-2-backend/Controllers/UserController.cs +++ b/rag-2-backend/Controllers/UserController.cs @@ -20,7 +20,7 @@ public void Register([FromBody] [Required] UserRequest userRequest) } [HttpPost("auth/login")] - public async Task Login([FromBody] [Required] UserRequest loginRequest) + public async Task Login([FromBody] [Required] UserLoginRequest loginRequest) { return await userService.LoginUser(loginRequest.Email, loginRequest.Password); } diff --git a/rag-2-backend/DTO/Mapper/UserMapper.cs b/rag-2-backend/DTO/Mapper/UserMapper.cs index 6ac4621..c92d432 100644 --- a/rag-2-backend/DTO/Mapper/UserMapper.cs +++ b/rag-2-backend/DTO/Mapper/UserMapper.cs @@ -11,7 +11,9 @@ public static UserResponse Map(User user) { Id = user.Id, Email = user.Email, - Role = user.Role + Role = user.Role, + StudyCycleYearA = user.StudyCycleYearA, + StudyCycleYearB = user.StudyCycleYearB, }; } } \ No newline at end of file diff --git a/rag-2-backend/DTO/UserLoginRequest.cs b/rag-2-backend/DTO/UserLoginRequest.cs new file mode 100644 index 0000000..aeb4ad2 --- /dev/null +++ b/rag-2-backend/DTO/UserLoginRequest.cs @@ -0,0 +1,7 @@ +namespace rag_2_backend.DTO; + +public class UserLoginRequest +{ + public required string Email { get; set; } + public required string Password { get; set; } +} \ No newline at end of file diff --git a/rag-2-backend/DTO/UserRequest.cs b/rag-2-backend/DTO/UserRequest.cs index f0d4048..bb63620 100644 --- a/rag-2-backend/DTO/UserRequest.cs +++ b/rag-2-backend/DTO/UserRequest.cs @@ -4,4 +4,6 @@ public class UserRequest { public required string Email { get; set; } public required string Password { get; set; } + public int StudyCycleYearA { get; init; } + public int StudyCycleYearB { get; init; } } \ No newline at end of file diff --git a/rag-2-backend/DTO/UserResponse.cs b/rag-2-backend/DTO/UserResponse.cs index 7519663..78567d5 100644 --- a/rag-2-backend/DTO/UserResponse.cs +++ b/rag-2-backend/DTO/UserResponse.cs @@ -9,4 +9,6 @@ public class UserResponse [Key] public required int Id { get; set; } public required string Email { get; set; } public Role Role { get; set; } + public required int StudyCycleYearA { get; set; } + public required int StudyCycleYearB { get; set; } } \ No newline at end of file diff --git a/rag-2-backend/Migrations/20240816183043_UserStudyYear.Designer.cs b/rag-2-backend/Migrations/20240816183043_UserStudyYear.Designer.cs new file mode 100644 index 0000000..046f417 --- /dev/null +++ b/rag-2-backend/Migrations/20240816183043_UserStudyYear.Designer.cs @@ -0,0 +1,179 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using rag_2_backend.data; + +#nullable disable + +namespace rag_2_backend.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20240816183043_UserStudyYear")] + partial class UserStudyYear + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("account_confirmation_token"); + }); + + modelBuilder.Entity("rag_2_backend.Models.Entity.BlacklistedJwt", b => + { + b.Property("Token") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Token"); + + b.ToTable("blacklisted_jwt"); + }); + + modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Confirmed") + .HasColumnType("boolean"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("StudyCycleYearA") + .HasColumnType("integer"); + + b.Property("StudyCycleYearB") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("users"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("games"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId"); + + b.ToTable("recorded_games"); + }); + + modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => + { + b.HasOne("rag_2_backend.Models.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => + { + b.HasOne("rag_2_backend.models.entity.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("rag_2_backend.Models.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/rag-2-backend/Migrations/20240816183043_UserStudyYear.cs b/rag-2-backend/Migrations/20240816183043_UserStudyYear.cs new file mode 100644 index 0000000..b067c95 --- /dev/null +++ b/rag-2-backend/Migrations/20240816183043_UserStudyYear.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace rag_2_backend.Migrations +{ + /// + public partial class UserStudyYear : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "StudyCycleYearA", + table: "users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "StudyCycleYearB", + table: "users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AlterColumn( + name: "Token", + table: "blacklisted_jwt", + type: "character varying(500)", + maxLength: 500, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "StudyCycleYearA", + table: "users"); + + migrationBuilder.DropColumn( + name: "StudyCycleYearB", + table: "users"); + + migrationBuilder.AlterColumn( + name: "Token", + table: "blacklisted_jwt", + type: "character varying(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(500)", + oldMaxLength: 500); + } + } +} diff --git a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs index efaa809..6c77ff9 100644 --- a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs +++ b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs @@ -44,8 +44,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("rag_2_backend.Models.Entity.BlacklistedJwt", b => { b.Property("Token") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasMaxLength(500) + .HasColumnType("character varying(500)"); b.Property("Expiration") .HasColumnType("timestamp without time zone"); @@ -79,6 +79,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Role") .HasColumnType("integer"); + b.Property("StudyCycleYearA") + .HasColumnType("integer"); + + b.Property("StudyCycleYearB") + .HasColumnType("integer"); + b.HasKey("Id"); b.ToTable("users"); diff --git a/rag-2-backend/Models/Entity/User.cs b/rag-2-backend/Models/Entity/User.cs index 104156d..621aee6 100644 --- a/rag-2-backend/Models/Entity/User.cs +++ b/rag-2-backend/Models/Entity/User.cs @@ -8,10 +8,12 @@ namespace rag_2_backend.Models.Entity; public class User { [Key] public int Id { get; init; } - [MaxLength(100)] public string Email { get; set; } = ""; + [MaxLength(100)] public string Email { get; init; } = ""; [MaxLength(100)] public required string Password { get; init; } public Role Role { get; set; } - public bool Confirmed { get; set; } = false; + public bool Confirmed { get; set; } + public int StudyCycleYearA { get; init; } + public int StudyCycleYearB { get; init; } public User() //for ef { diff --git a/rag-2-backend/Program.cs b/rag-2-backend/Program.cs index 2e9009e..cb48422 100644 --- a/rag-2-backend/Program.cs +++ b/rag-2-backend/Program.cs @@ -1,3 +1,4 @@ +using System.IdentityModel.Tokens.Jwt; using Microsoft.EntityFrameworkCore; using System.Text.Json.Serialization; using rag_2_backend.data; @@ -101,6 +102,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/rag-2-backend/Services/UserService.cs b/rag-2-backend/Services/UserService.cs index d683e21..628420f 100644 --- a/rag-2-backend/Services/UserService.cs +++ b/rag-2-backend/Services/UserService.cs @@ -3,6 +3,7 @@ using rag_2_backend.data; using rag_2_backend.DTO; using rag_2_backend.DTO.Mapper; +using rag_2_backend.Models; using rag_2_backend.Models.Entity; using rag_2_backend.Utils; @@ -21,8 +22,15 @@ public void RegisterUser(UserRequest userRequest) User user = new(userRequest.Email) { - Password = HashUtil.HashPassword(userRequest.Password) + Password = HashUtil.HashPassword(userRequest.Password), + StudyCycleYearA = userRequest.StudyCycleYearA, + StudyCycleYearB = userRequest.StudyCycleYearB, }; + if (user.Role == Role.Student) + { + if(userRequest.StudyCycleYearA == 0 || userRequest.StudyCycleYearB == 0 || userRequest.StudyCycleYearB - userRequest.StudyCycleYearA != 1) + throw new BadHttpRequestException("Wrong study cycle year"); + } context.Users.Add(user); GenerateAccountTokenAndSendConfirmationMail(user); diff --git a/rag-2-backend/Test/GameRecordServiceTest.cs b/rag-2-backend/Test/GameRecordServiceTest.cs index 3080ef9..8f4e42a 100644 --- a/rag-2-backend/Test/GameRecordServiceTest.cs +++ b/rag-2-backend/Test/GameRecordServiceTest.cs @@ -24,6 +24,8 @@ public class GameRecordServiceTest { Id = 1, Password = "password", + StudyCycleYearA = 2022, + StudyCycleYearB = 2023 }; private readonly Game _game = new() @@ -68,7 +70,7 @@ public async void GetRecordsByGameTest() Id = 1, Value = "10", GameResponse = new GameResponse { Id = 1, Name = "Game1", GameType = GameType.EventGame }, - UserResponse = new UserResponse { Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher }, + UserResponse = new UserResponse { Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher, StudyCycleYearA = 2022, StudyCycleYearB = 2023}, }, ]; diff --git a/rag-2-backend/Test/UserServiceTest.cs b/rag-2-backend/Test/UserServiceTest.cs index dcb05bb..87bf4a3 100644 --- a/rag-2-backend/Test/UserServiceTest.cs +++ b/rag-2-backend/Test/UserServiceTest.cs @@ -29,6 +29,8 @@ public class UserServiceTest { Id = 1, Password = HashUtil.HashPassword("password"), + StudyCycleYearA = 2022, + StudyCycleYearB = 2023 }; private readonly AccountConfirmationToken _token; @@ -59,12 +61,18 @@ public UserServiceTest() public void ShouldRegisterUser() { _userService.RegisterUser(new UserRequest - { Email = "email1@prz.edu.pl", Password = "pass" } + { Email = "email1@prz.edu.pl", Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023} ); _contextMock.Verify(c => c.Users.Add(It.IsAny()), Times.Once); _contextMock.Verify(c => c.AccountConfirmationTokens.Add(It.IsAny()), Times.Once); _emailService.Verify(e => e.SendConfirmationEmail(It.IsAny(), It.IsAny()), Times.Once); + + Assert.Throws( + ()=>_userService.RegisterUser(new UserRequest + { Email = "email1@stud.prz.edu.pl", Password = "pass", StudyCycleYearA = 2020, StudyCycleYearB = 2023} + ) + ); } [Fact] @@ -122,6 +130,8 @@ public void ShouldGetMe() Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher, + StudyCycleYearA = 2022, + StudyCycleYearB = 2023 }; Assert.Equal(JsonConvert.SerializeObject(userResponse), diff --git a/rag-2-backend/Test/UserTest.cs b/rag-2-backend/Test/UserTest.cs index 8b963f9..59bc970 100644 --- a/rag-2-backend/Test/UserTest.cs +++ b/rag-2-backend/Test/UserTest.cs @@ -11,14 +11,14 @@ public void ShouldCreateUser() { Assert.Equal( Role.Student, - new User("index@stud.prz.edu.pl") { Password = "pass" }.Role + new User("index@stud.prz.edu.pl") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023}.Role ); Assert.Equal( Role.Teacher, - new User("index@prz.edu.pl") { Password = "pass" }.Role + new User("index@prz.edu.pl") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023}.Role ); Assert.Throws( - () => new User("index@gmail.com") { Password = "pass" } + () => new User("index@gmail.com") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023} ); } } \ No newline at end of file From f113c839e4f0b02a366dfd4454c6f121fd32c98b Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Sat, 17 Aug 2024 12:25:35 +0200 Subject: [PATCH 10/12] feat: #8 password reset workflow --- rag-2-backend/Controllers/UserController.cs | 14 +- rag-2-backend/Data/DatabaseContext.cs | 1 + .../20240815163557_BaseEntity.Designer.cs | 159 ---------------- ...20240816164555_BlacklistedJwts.Designer.cs | 173 ------------------ .../20240816164555_BlacklistedJwts.cs | 34 ---- .../20240816183043_UserStudyYear.cs | 60 ------ ...=> 20240817102412_BasicEntity.Designer.cs} | 34 +++- ...ntity.cs => 20240817102412_BasicEntity.cs} | 48 ++++- .../DatabaseContextModelSnapshot.cs | 30 +++ .../Models/Entity/PasswordResetToken.cs | 12 ++ rag-2-backend/Models/Entity/User.cs | 2 +- .../Services/BackgroundServiceImpl.cs | 17 +- rag-2-backend/Services/EmailService.cs | 10 + rag-2-backend/Services/UserService.cs | 44 ++++- rag-2-backend/Test/UserServiceTest.cs | 43 ++++- rag-2-backend/Utils/TokenGenerationUtil.cs | 4 +- rag-2-backend/appsettings.Development.json | 3 +- 17 files changed, 243 insertions(+), 445 deletions(-) delete mode 100644 rag-2-backend/Migrations/20240815163557_BaseEntity.Designer.cs delete mode 100644 rag-2-backend/Migrations/20240816164555_BlacklistedJwts.Designer.cs delete mode 100644 rag-2-backend/Migrations/20240816164555_BlacklistedJwts.cs delete mode 100644 rag-2-backend/Migrations/20240816183043_UserStudyYear.cs rename rag-2-backend/Migrations/{20240816183043_UserStudyYear.Designer.cs => 20240817102412_BasicEntity.Designer.cs} (84%) rename rag-2-backend/Migrations/{20240815163557_BaseEntity.cs => 20240817102412_BasicEntity.cs} (72%) create mode 100644 rag-2-backend/Models/Entity/PasswordResetToken.cs diff --git a/rag-2-backend/Controllers/UserController.cs b/rag-2-backend/Controllers/UserController.cs index 3e8b456..0962414 100644 --- a/rag-2-backend/Controllers/UserController.cs +++ b/rag-2-backend/Controllers/UserController.cs @@ -50,7 +50,7 @@ public void ConfirmAccount([Required] string token) /// /// (Auth) /// - [HttpGet("me")] + [HttpGet("auth/me")] [Authorize] public async Task Me() { @@ -58,4 +58,16 @@ public async Task Me() return await userService.GetMe(email); } + + [HttpPost("auth/request-password-reset")] + public void RequestPasswordReset([Required] string email) + { + userService.RequestPasswordReset(email); + } + + [HttpPost("auth/reset-password")] + public void ResetPassword([Required] string tokenValue, [Required] string newPassword) + { + userService.ResetPassword(tokenValue, newPassword); + } } \ No newline at end of file diff --git a/rag-2-backend/Data/DatabaseContext.cs b/rag-2-backend/Data/DatabaseContext.cs index 81f3e2a..e32fbb3 100644 --- a/rag-2-backend/Data/DatabaseContext.cs +++ b/rag-2-backend/Data/DatabaseContext.cs @@ -11,4 +11,5 @@ public class DatabaseContext(DbContextOptions options) : DbCont public virtual required DbSet Users { get; init; } public virtual required DbSet AccountConfirmationTokens { get; init; } public virtual required DbSet BlacklistedJwts { get; init; } + public virtual required DbSet PasswordResetTokens { get; init; } } \ No newline at end of file diff --git a/rag-2-backend/Migrations/20240815163557_BaseEntity.Designer.cs b/rag-2-backend/Migrations/20240815163557_BaseEntity.Designer.cs deleted file mode 100644 index ab0d03e..0000000 --- a/rag-2-backend/Migrations/20240815163557_BaseEntity.Designer.cs +++ /dev/null @@ -1,159 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using rag_2_backend.data; - -#nullable disable - -namespace rag_2_backend.Migrations -{ - [DbContext(typeof(DatabaseContext))] - [Migration("20240815163557_BaseEntity")] - partial class BaseEntity - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => - { - b.Property("Token") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Expiration") - .HasColumnType("timestamp without time zone"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Token"); - - b.HasIndex("UserId"); - - b.ToTable("account_confirmation_token"); - }); - - modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Confirmed") - .HasColumnType("boolean"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Password") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Role") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("users"); - }); - - modelBuilder.Entity("rag_2_backend.models.entity.Game", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("GameType") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("games"); - }); - - modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("GameId") - .HasColumnType("integer"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.Property("Value") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("GameId"); - - b.HasIndex("UserId"); - - b.ToTable("recorded_games"); - }); - - modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => - { - b.HasOne("rag_2_backend.Models.Entity.User", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => - { - b.HasOne("rag_2_backend.models.entity.Game", "Game") - .WithMany() - .HasForeignKey("GameId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("rag_2_backend.Models.Entity.User", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Game"); - - b.Navigation("User"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.Designer.cs b/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.Designer.cs deleted file mode 100644 index 2c26c4f..0000000 --- a/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.Designer.cs +++ /dev/null @@ -1,173 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using rag_2_backend.data; - -#nullable disable - -namespace rag_2_backend.Migrations -{ - [DbContext(typeof(DatabaseContext))] - [Migration("20240816164555_BlacklistedJwts")] - partial class BlacklistedJwts - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => - { - b.Property("Token") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Expiration") - .HasColumnType("timestamp without time zone"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Token"); - - b.HasIndex("UserId"); - - b.ToTable("account_confirmation_token"); - }); - - modelBuilder.Entity("rag_2_backend.Models.Entity.BlacklistedJwt", b => - { - b.Property("Token") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Expiration") - .HasColumnType("timestamp without time zone"); - - b.HasKey("Token"); - - b.ToTable("blacklisted_jwt"); - }); - - modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Confirmed") - .HasColumnType("boolean"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Password") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Role") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("users"); - }); - - modelBuilder.Entity("rag_2_backend.models.entity.Game", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("GameType") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("games"); - }); - - modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("GameId") - .HasColumnType("integer"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.Property("Value") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("GameId"); - - b.HasIndex("UserId"); - - b.ToTable("recorded_games"); - }); - - modelBuilder.Entity("rag_2_backend.Models.Entity.AccountConfirmationToken", b => - { - b.HasOne("rag_2_backend.Models.Entity.User", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => - { - b.HasOne("rag_2_backend.models.entity.Game", "Game") - .WithMany() - .HasForeignKey("GameId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("rag_2_backend.Models.Entity.User", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Game"); - - b.Navigation("User"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.cs b/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.cs deleted file mode 100644 index 8bf1205..0000000 --- a/rag-2-backend/Migrations/20240816164555_BlacklistedJwts.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace rag_2_backend.Migrations -{ - /// - public partial class BlacklistedJwts : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "blacklisted_jwt", - columns: table => new - { - Token = table.Column(type: "character varying(100)", maxLength: 500, nullable: false), - Expiration = table.Column(type: "timestamp without time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_blacklisted_jwt", x => x.Token); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "blacklisted_jwt"); - } - } -} diff --git a/rag-2-backend/Migrations/20240816183043_UserStudyYear.cs b/rag-2-backend/Migrations/20240816183043_UserStudyYear.cs deleted file mode 100644 index b067c95..0000000 --- a/rag-2-backend/Migrations/20240816183043_UserStudyYear.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace rag_2_backend.Migrations -{ - /// - public partial class UserStudyYear : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "StudyCycleYearA", - table: "users", - type: "integer", - nullable: false, - defaultValue: 0); - - migrationBuilder.AddColumn( - name: "StudyCycleYearB", - table: "users", - type: "integer", - nullable: false, - defaultValue: 0); - - migrationBuilder.AlterColumn( - name: "Token", - table: "blacklisted_jwt", - type: "character varying(500)", - maxLength: 500, - nullable: false, - oldClrType: typeof(string), - oldType: "character varying(100)", - oldMaxLength: 100); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "StudyCycleYearA", - table: "users"); - - migrationBuilder.DropColumn( - name: "StudyCycleYearB", - table: "users"); - - migrationBuilder.AlterColumn( - name: "Token", - table: "blacklisted_jwt", - type: "character varying(100)", - maxLength: 100, - nullable: false, - oldClrType: typeof(string), - oldType: "character varying(500)", - oldMaxLength: 500); - } - } -} diff --git a/rag-2-backend/Migrations/20240816183043_UserStudyYear.Designer.cs b/rag-2-backend/Migrations/20240817102412_BasicEntity.Designer.cs similarity index 84% rename from rag-2-backend/Migrations/20240816183043_UserStudyYear.Designer.cs rename to rag-2-backend/Migrations/20240817102412_BasicEntity.Designer.cs index 046f417..9756cea 100644 --- a/rag-2-backend/Migrations/20240816183043_UserStudyYear.Designer.cs +++ b/rag-2-backend/Migrations/20240817102412_BasicEntity.Designer.cs @@ -12,8 +12,8 @@ namespace rag_2_backend.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20240816183043_UserStudyYear")] - partial class UserStudyYear + [Migration("20240817102412_BasicEntity")] + partial class BasicEntity { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -58,6 +58,25 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("blacklisted_jwt"); }); + modelBuilder.Entity("rag_2_backend.Models.Entity.PasswordResetToken", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_token"); + }); + modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => { b.Property("Id") @@ -155,6 +174,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("rag_2_backend.Models.Entity.PasswordResetToken", b => + { + b.HasOne("rag_2_backend.Models.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => { b.HasOne("rag_2_backend.models.entity.Game", "Game") diff --git a/rag-2-backend/Migrations/20240815163557_BaseEntity.cs b/rag-2-backend/Migrations/20240817102412_BasicEntity.cs similarity index 72% rename from rag-2-backend/Migrations/20240815163557_BaseEntity.cs rename to rag-2-backend/Migrations/20240817102412_BasicEntity.cs index b7d9b8a..3df400d 100644 --- a/rag-2-backend/Migrations/20240815163557_BaseEntity.cs +++ b/rag-2-backend/Migrations/20240817102412_BasicEntity.cs @@ -7,11 +7,23 @@ namespace rag_2_backend.Migrations { /// - public partial class BaseEntity : Migration + public partial class BasicEntity : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.CreateTable( + name: "blacklisted_jwt", + columns: table => new + { + Token = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Expiration = table.Column(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_blacklisted_jwt", x => x.Token); + }); + migrationBuilder.CreateTable( name: "games", columns: table => new @@ -35,7 +47,9 @@ protected override void Up(MigrationBuilder migrationBuilder) Email = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), Password = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), Role = table.Column(type: "integer", nullable: false), - Confirmed = table.Column(type: "boolean", nullable: false) + Confirmed = table.Column(type: "boolean", nullable: false), + StudyCycleYearA = table.Column(type: "integer", nullable: false), + StudyCycleYearB = table.Column(type: "integer", nullable: false) }, constraints: table => { @@ -61,6 +75,25 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "password_reset_token", + columns: table => new + { + Token = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Expiration = table.Column(type: "timestamp without time zone", nullable: false), + UserId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_password_reset_token", x => x.Token); + table.ForeignKey( + name: "FK_password_reset_token_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "recorded_games", columns: table => new @@ -99,6 +132,11 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "Name", unique: true); + migrationBuilder.CreateIndex( + name: "IX_password_reset_token_UserId", + table: "password_reset_token", + column: "UserId"); + migrationBuilder.CreateIndex( name: "IX_recorded_games_GameId", table: "recorded_games", @@ -116,6 +154,12 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "account_confirmation_token"); + migrationBuilder.DropTable( + name: "blacklisted_jwt"); + + migrationBuilder.DropTable( + name: "password_reset_token"); + migrationBuilder.DropTable( name: "recorded_games"); diff --git a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs index 6c77ff9..691e6af 100644 --- a/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs +++ b/rag-2-backend/Migrations/DatabaseContextModelSnapshot.cs @@ -55,6 +55,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("blacklisted_jwt"); }); + modelBuilder.Entity("rag_2_backend.Models.Entity.PasswordResetToken", b => + { + b.Property("Token") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Expiration") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_token"); + }); + modelBuilder.Entity("rag_2_backend.Models.Entity.User", b => { b.Property("Id") @@ -152,6 +171,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("rag_2_backend.Models.Entity.PasswordResetToken", b => + { + b.HasOne("rag_2_backend.Models.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("rag_2_backend.models.entity.RecordedGame", b => { b.HasOne("rag_2_backend.models.entity.Game", "Game") diff --git a/rag-2-backend/Models/Entity/PasswordResetToken.cs b/rag-2-backend/Models/Entity/PasswordResetToken.cs new file mode 100644 index 0000000..ce8ee19 --- /dev/null +++ b/rag-2-backend/Models/Entity/PasswordResetToken.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace rag_2_backend.Models.Entity; + +[Table("password_reset_token")] +public class PasswordResetToken +{ + [Key] [MaxLength(100)] public required string Token { get; init; } + public required DateTime Expiration { get; set; } + public required User User { get; init; } +} \ No newline at end of file diff --git a/rag-2-backend/Models/Entity/User.cs b/rag-2-backend/Models/Entity/User.cs index 621aee6..97683fb 100644 --- a/rag-2-backend/Models/Entity/User.cs +++ b/rag-2-backend/Models/Entity/User.cs @@ -9,7 +9,7 @@ public class User { [Key] public int Id { get; init; } [MaxLength(100)] public string Email { get; init; } = ""; - [MaxLength(100)] public required string Password { get; init; } + [MaxLength(100)] public required string Password { get; set; } public Role Role { get; set; } public bool Confirmed { get; set; } public int StudyCycleYearA { get; init; } diff --git a/rag-2-backend/Services/BackgroundServiceImpl.cs b/rag-2-backend/Services/BackgroundServiceImpl.cs index ba87cce..5854936 100644 --- a/rag-2-backend/Services/BackgroundServiceImpl.cs +++ b/rag-2-backend/Services/BackgroundServiceImpl.cs @@ -10,14 +10,15 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { - CheckUnusedAccountTokens(); + DeleteUnusedAccountTokens(); DeleteUnusedBlacklistedJwts(); + DeleteUnusedPasswordResetTokens(); await Task.Delay(TimeSpan.FromDays(1), cancellationToken); } } - private void CheckUnusedAccountTokens() + private void DeleteUnusedAccountTokens() { using var scope = serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -49,4 +50,16 @@ private void DeleteUnusedBlacklistedJwts() Console.WriteLine("Deleted" + unusedTokens.Count + " blacklisted jwts"); } + + private void DeleteUnusedPasswordResetTokens() + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var unusedTokens = dbContext.PasswordResetTokens.Where(b => b.Expiration < DateTime.Now).ToList(); + dbContext.PasswordResetTokens.RemoveRange(unusedTokens); + dbContext.SaveChanges(); + + Console.WriteLine("Deleted" + unusedTokens.Count + " password reset tokens"); + } } \ No newline at end of file diff --git a/rag-2-backend/Services/EmailService.cs b/rag-2-backend/Services/EmailService.cs index 079cef1..3c0afdf 100644 --- a/rag-2-backend/Services/EmailService.cs +++ b/rag-2-backend/Services/EmailService.cs @@ -13,4 +13,14 @@ public virtual void SendConfirmationEmail(string to, string token) Task.Run(async () => await emailSendingUtil.SendMail(to, "Confirmation email", body)); } + + public virtual void SendPasswordResetMail(string to, string token) + { + var address = config.GetValue("FrontendURLs:PasswordResetURL") + token; + var body = "Reset your password by clicking this button: Reset"; + + Task.Run(async () => + await emailSendingUtil.SendMail(to, "Password reset", body)); + } } \ No newline at end of file diff --git a/rag-2-backend/Services/UserService.cs b/rag-2-backend/Services/UserService.cs index 628420f..e9e8f08 100644 --- a/rag-2-backend/Services/UserService.cs +++ b/rag-2-backend/Services/UserService.cs @@ -104,17 +104,59 @@ public void LogoutUser(string header) context.SaveChanges(); } + public void RequestPasswordReset(string email) + { + var user = context.Users.SingleOrDefault(u => u.Email == email) ?? + throw new KeyNotFoundException("User not found"); + + context.PasswordResetTokens.RemoveRange( + context.PasswordResetTokens.Where(a => a.User.Email == user.Email) + ); + + GeneratePasswordResetTokenAndSendMail(user); + + context.SaveChanges(); + } + + public void ResetPassword(string tokenValue, string newPassword) + { + var token = context.PasswordResetTokens + .Include(t => t.User) + .SingleOrDefault(t => t.Token == tokenValue) + ?? throw new BadHttpRequestException("Invalid token"); + if (token.Expiration < DateTime.Now) throw new BadHttpRequestException("Invalid token"); + + var user = context.Users.SingleOrDefault(u => u.Email == token.User.Email) ?? + throw new KeyNotFoundException("User not found"); + user.Password = HashUtil.HashPassword(newPassword); + + context.PasswordResetTokens.Remove(token); + context.SaveChanges(); + } + // private void GenerateAccountTokenAndSendConfirmationMail(User user) { var token = new AccountConfirmationToken { - Token = TokenGenerationUtil.CreatePassword(15), + Token = TokenGenerationUtil.GenerateToken(15), User = user, Expiration = DateTime.Now.AddDays(7) }; context.AccountConfirmationTokens.Add(token); emailService.SendConfirmationEmail(user.Email, token.Token); } + + private void GeneratePasswordResetTokenAndSendMail(User user) + { + var token = new PasswordResetToken + { + Token = TokenGenerationUtil.GenerateToken(15), + User = user, + Expiration = DateTime.Now.AddDays(7) + }; + context.PasswordResetTokens.Add(token); + emailService.SendPasswordResetMail(user.Email, token.Token); + } } \ No newline at end of file diff --git a/rag-2-backend/Test/UserServiceTest.cs b/rag-2-backend/Test/UserServiceTest.cs index 87bf4a3..f4453b6 100644 --- a/rag-2-backend/Test/UserServiceTest.cs +++ b/rag-2-backend/Test/UserServiceTest.cs @@ -5,7 +5,6 @@ using Newtonsoft.Json; using rag_2_backend.data; using rag_2_backend.DTO; -using rag_2_backend.Migrations; using rag_2_backend.Models; using rag_2_backend.Models.Entity; using rag_2_backend.Services; @@ -33,11 +32,18 @@ public class UserServiceTest StudyCycleYearB = 2023 }; - private readonly AccountConfirmationToken _token; + private readonly AccountConfirmationToken _accountToken; + private readonly PasswordResetToken _passwordToken; public UserServiceTest() { - _token = new AccountConfirmationToken + _accountToken = new AccountConfirmationToken + { + User = _user, + Expiration = DateTime.Now.AddDays(7), + Token = HashUtil.HashPassword("password"), + }; + _passwordToken = new PasswordResetToken() { User = _user, Expiration = DateTime.Now.AddDays(7), @@ -49,11 +55,14 @@ public UserServiceTest() _contextMock.Setup(c => c.Users).Returns(() => new List { _user } .AsQueryable().BuildMockDbSet().Object); _contextMock.Setup(c => c.AccountConfirmationTokens) - .Returns(() => new List { _token }.AsQueryable().BuildMockDbSet().Object); + .Returns(() => new List { _accountToken }.AsQueryable().BuildMockDbSet().Object); _contextMock.Setup(c => c.BlacklistedJwts) .Returns(() => new List().AsQueryable().BuildMockDbSet().Object); + _contextMock.Setup(c => c.PasswordResetTokens) + .Returns(() => new List(){_passwordToken}.AsQueryable().BuildMockDbSet().Object); _jwtUtilMock.Setup(j => j.GenerateToken(It.IsAny(), It.IsAny())).Verifiable(); _emailService.Setup(e => e.SendConfirmationEmail(It.IsAny(), It.IsAny())).Verifiable(); + _emailService.Setup(e=>e.SendPasswordResetMail(It.IsAny(), It.IsAny())).Verifiable(); _jwtSecurityTokenHandlerMock.Setup(e => e.ReadToken(It.IsAny())).Returns(() => new JwtSecurityToken()); } @@ -90,12 +99,12 @@ public void ShouldResendConfirmationMail() [Fact] public void ShouldConfirmAccount() { - _userService.ConfirmAccount(_token.Token); + _userService.ConfirmAccount(_accountToken.Token); _contextMock.Verify(c => c.AccountConfirmationTokens.Remove(It.IsAny()), Times.Once); Assert.Throws(() => _userService.ConfirmAccount("token")); //wrong token - _token.Expiration = DateTime.Now.AddDays(-7); - Assert.Throws(() => _userService.ConfirmAccount(_token.Token)); //invalid date + _accountToken.Expiration = DateTime.Now.AddDays(-7); + Assert.Throws(() => _userService.ConfirmAccount(_accountToken.Token)); //invalid date } [Fact] @@ -137,4 +146,24 @@ public void ShouldGetMe() Assert.Equal(JsonConvert.SerializeObject(userResponse), JsonConvert.SerializeObject(_userService.GetMe("email@prz.edu.pl").Result)); } + + [Fact] + public void ShouldRequestPasswordReset() + { + _userService.RequestPasswordReset("email@prz.edu.pl"); + + _contextMock.Verify(c => c.PasswordResetTokens.Add(It.IsAny()), Times.Once); + _emailService.Verify(e => e.SendPasswordResetMail(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void ShouldResetPassword() + { + _userService.ResetPassword(_passwordToken.Token, "pass"); + _contextMock.Verify(c => c.PasswordResetTokens.Remove(It.IsAny()), Times.Once); + + Assert.Throws(() => _userService.ResetPassword("token1", "pass")); //wrong token + _passwordToken.Expiration = DateTime.Now.AddDays(-7); + Assert.Throws(() => _userService.ResetPassword(_passwordToken.Token, "pass")); //invalid date + } } \ No newline at end of file diff --git a/rag-2-backend/Utils/TokenGenerationUtil.cs b/rag-2-backend/Utils/TokenGenerationUtil.cs index bf6767c..53c729a 100644 --- a/rag-2-backend/Utils/TokenGenerationUtil.cs +++ b/rag-2-backend/Utils/TokenGenerationUtil.cs @@ -2,9 +2,9 @@ namespace rag_2_backend.Utils; -public class TokenGenerationUtil +public abstract class TokenGenerationUtil { - public static string CreatePassword(int length) + public static string GenerateToken(int length) { const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; var res = new StringBuilder(); diff --git a/rag-2-backend/appsettings.Development.json b/rag-2-backend/appsettings.Development.json index 5f343e6..9fcad44 100644 --- a/rag-2-backend/appsettings.Development.json +++ b/rag-2-backend/appsettings.Development.json @@ -9,6 +9,7 @@ "DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" }, "FrontendURLs": { - "MailConfirmationURL": "http://localhost:4200/user/confirm/?token=" + "MailConfirmationURL": "http://localhost:4200/user/confirm/?token=", + "PasswordResetURL": "http://localhost:4200/user/reset-password/?token=" } } From 4acb8a2417bf656a28b876e0116df69bd15e587d Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Sat, 17 Aug 2024 12:47:12 +0200 Subject: [PATCH 11/12] feat: #8 password change and account deletion --- rag-2-backend/Controllers/UserController.cs | 20 ++++++++++ rag-2-backend/Services/UserService.cs | 42 ++++++++++++++++++++- rag-2-backend/Test/GameRecordServiceTest.cs | 6 ++- rag-2-backend/Test/UserServiceTest.cs | 32 +++++++++++++--- rag-2-backend/Test/UserTest.cs | 6 +-- 5 files changed, 95 insertions(+), 11 deletions(-) diff --git a/rag-2-backend/Controllers/UserController.cs b/rag-2-backend/Controllers/UserController.cs index 0962414..15bed7f 100644 --- a/rag-2-backend/Controllers/UserController.cs +++ b/rag-2-backend/Controllers/UserController.cs @@ -70,4 +70,24 @@ public void ResetPassword([Required] string tokenValue, [Required] string newPas { userService.ResetPassword(tokenValue, newPassword); } + + [HttpPost("auth/change-password")] + [Authorize] + public void ChangePassword([Required] string oldPassword, [Required] string newPassword) + { + var email = User.FindFirst(ClaimTypes.Email)?.Value ?? throw new UnauthorizedAccessException("Unauthorized"); + + userService.ChangePassword(email, oldPassword, newPassword); + } + + [HttpPost("auth/delete-account")] + [Authorize] + public void DeleteAccount() + { + var email = User.FindFirst(ClaimTypes.Email)?.Value ?? throw new UnauthorizedAccessException("Unauthorized"); + var header = HttpContext.Request.Headers.Authorization.FirstOrDefault() ?? + throw new UnauthorizedAccessException("Unauthorized"); + + userService.DeleteAccount(email, header); + } } \ No newline at end of file diff --git a/rag-2-backend/Services/UserService.cs b/rag-2-backend/Services/UserService.cs index e9e8f08..656ab95 100644 --- a/rag-2-backend/Services/UserService.cs +++ b/rag-2-backend/Services/UserService.cs @@ -28,9 +28,11 @@ public void RegisterUser(UserRequest userRequest) }; if (user.Role == Role.Student) { - if(userRequest.StudyCycleYearA == 0 || userRequest.StudyCycleYearB == 0 || userRequest.StudyCycleYearB - userRequest.StudyCycleYearA != 1) + if (userRequest.StudyCycleYearA == 0 || userRequest.StudyCycleYearB == 0 || + userRequest.StudyCycleYearB - userRequest.StudyCycleYearA != 1) throw new BadHttpRequestException("Wrong study cycle year"); } + context.Users.Add(user); GenerateAccountTokenAndSendConfirmationMail(user); @@ -134,6 +136,44 @@ public void ResetPassword(string tokenValue, string newPassword) context.SaveChanges(); } + public void ChangePassword(string email, string oldPassword, string newPassword) + { + var user = context.Users.SingleOrDefault(u => u.Email == email) ?? + throw new KeyNotFoundException("User not found"); + + if (!HashUtil.VerifyPassword(oldPassword, user.Password)) + throw new BadHttpRequestException("Invalid old password"); + if (user.Password == HashUtil.HashPassword(newPassword)) + throw new BadHttpRequestException("Password cannot be same"); + + user.Password = HashUtil.HashPassword(newPassword); + context.SaveChanges(); + } + + public void DeleteAccount(string email, string header) + { + var user = context.Users.SingleOrDefault(u => u.Email == email) ?? + throw new KeyNotFoundException("User not found"); + + context.PasswordResetTokens.RemoveRange(context.PasswordResetTokens + .Include(p => p.User) + .Where(a => a.User.Email == email) + ); + context.AccountConfirmationTokens.RemoveRange(context.AccountConfirmationTokens + .Include(p => p.User) + .Where(a => a.User.Email == email) + ); + context.RecordedGames.RemoveRange(context.RecordedGames + .Include(p => p.User) + .Where(a => a.User.Email == email) + ); + + context.Users.Remove(user); + context.SaveChanges(); + + LogoutUser(header); + } + // private void GenerateAccountTokenAndSendConfirmationMail(User user) diff --git a/rag-2-backend/Test/GameRecordServiceTest.cs b/rag-2-backend/Test/GameRecordServiceTest.cs index 8f4e42a..b40b664 100644 --- a/rag-2-backend/Test/GameRecordServiceTest.cs +++ b/rag-2-backend/Test/GameRecordServiceTest.cs @@ -70,7 +70,11 @@ public async void GetRecordsByGameTest() Id = 1, Value = "10", GameResponse = new GameResponse { Id = 1, Name = "Game1", GameType = GameType.EventGame }, - UserResponse = new UserResponse { Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher, StudyCycleYearA = 2022, StudyCycleYearB = 2023}, + UserResponse = new UserResponse + { + Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher, StudyCycleYearA = 2022, + StudyCycleYearB = 2023 + }, }, ]; diff --git a/rag-2-backend/Test/UserServiceTest.cs b/rag-2-backend/Test/UserServiceTest.cs index f4453b6..ce0379c 100644 --- a/rag-2-backend/Test/UserServiceTest.cs +++ b/rag-2-backend/Test/UserServiceTest.cs @@ -6,6 +6,7 @@ using rag_2_backend.data; using rag_2_backend.DTO; using rag_2_backend.Models; +using rag_2_backend.models.entity; using rag_2_backend.Models.Entity; using rag_2_backend.Services; using rag_2_backend.Utils; @@ -58,11 +59,13 @@ public UserServiceTest() .Returns(() => new List { _accountToken }.AsQueryable().BuildMockDbSet().Object); _contextMock.Setup(c => c.BlacklistedJwts) .Returns(() => new List().AsQueryable().BuildMockDbSet().Object); + _contextMock.Setup(c => c.RecordedGames) + .Returns(() => new List().AsQueryable().BuildMockDbSet().Object); _contextMock.Setup(c => c.PasswordResetTokens) - .Returns(() => new List(){_passwordToken}.AsQueryable().BuildMockDbSet().Object); + .Returns(() => new List() { _passwordToken }.AsQueryable().BuildMockDbSet().Object); _jwtUtilMock.Setup(j => j.GenerateToken(It.IsAny(), It.IsAny())).Verifiable(); _emailService.Setup(e => e.SendConfirmationEmail(It.IsAny(), It.IsAny())).Verifiable(); - _emailService.Setup(e=>e.SendPasswordResetMail(It.IsAny(), It.IsAny())).Verifiable(); + _emailService.Setup(e => e.SendPasswordResetMail(It.IsAny(), It.IsAny())).Verifiable(); _jwtSecurityTokenHandlerMock.Setup(e => e.ReadToken(It.IsAny())).Returns(() => new JwtSecurityToken()); } @@ -70,7 +73,7 @@ public UserServiceTest() public void ShouldRegisterUser() { _userService.RegisterUser(new UserRequest - { Email = "email1@prz.edu.pl", Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023} + { Email = "email1@prz.edu.pl", Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023 } ); _contextMock.Verify(c => c.Users.Add(It.IsAny()), Times.Once); @@ -78,8 +81,8 @@ public void ShouldRegisterUser() _emailService.Verify(e => e.SendConfirmationEmail(It.IsAny(), It.IsAny()), Times.Once); Assert.Throws( - ()=>_userService.RegisterUser(new UserRequest - { Email = "email1@stud.prz.edu.pl", Password = "pass", StudyCycleYearA = 2020, StudyCycleYearB = 2023} + () => _userService.RegisterUser(new UserRequest + { Email = "email1@stud.prz.edu.pl", Password = "pass", StudyCycleYearA = 2020, StudyCycleYearB = 2023 } ) ); } @@ -164,6 +167,23 @@ public void ShouldResetPassword() Assert.Throws(() => _userService.ResetPassword("token1", "pass")); //wrong token _passwordToken.Expiration = DateTime.Now.AddDays(-7); - Assert.Throws(() => _userService.ResetPassword(_passwordToken.Token, "pass")); //invalid date + Assert.Throws(() => + _userService.ResetPassword(_passwordToken.Token, "pass")); //invalid date + } + + [Fact] + public void ShouldChangePassword() + { + _userService.ChangePassword("email@prz.edu.pl", "password", "pass2"); + + Assert.Throws(() => _userService.ChangePassword("email@prz.edu.pl", "pa4ss2", "pas2")); + } + + [Fact] + public void ShouldDeleteAccount() + { + _userService.DeleteAccount("email@prz.edu.pl", "Bearer header"); + + _contextMock.Verify(c => c.Users.Remove(It.IsAny()), Times.Once); } } \ No newline at end of file diff --git a/rag-2-backend/Test/UserTest.cs b/rag-2-backend/Test/UserTest.cs index 59bc970..0198fb1 100644 --- a/rag-2-backend/Test/UserTest.cs +++ b/rag-2-backend/Test/UserTest.cs @@ -11,14 +11,14 @@ public void ShouldCreateUser() { Assert.Equal( Role.Student, - new User("index@stud.prz.edu.pl") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023}.Role + new User("index@stud.prz.edu.pl") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023 }.Role ); Assert.Equal( Role.Teacher, - new User("index@prz.edu.pl") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023}.Role + new User("index@prz.edu.pl") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023 }.Role ); Assert.Throws( - () => new User("index@gmail.com") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023} + () => new User("index@gmail.com") { Password = "pass", StudyCycleYearA = 2022, StudyCycleYearB = 2023 } ); } } \ No newline at end of file From 5feb07bed81c42382158113e95b5699d7f03b545 Mon Sep 17 00:00:00 2001 From: Marcin Bator Date: Sat, 17 Aug 2024 13:07:25 +0200 Subject: [PATCH 12/12] refactor: #8 code refactor --- .../{Data => Config}/DatabaseContext.cs | 0 .../ExceptionHandlingMiddleware.cs | 4 +- rag-2-backend/Controllers/GameController.cs | 6 +- .../Controllers/GameRecordController.cs | 6 +- rag-2-backend/Controllers/UserController.cs | 75 ++++++++++--------- rag-2-backend/DTO/GameResponse.cs | 13 ++-- rag-2-backend/DTO/Mapper/GameMapper.cs | 7 +- .../DTO/Mapper/RecordedGameMapper.cs | 7 +- rag-2-backend/DTO/Mapper/UserMapper.cs | 9 +-- rag-2-backend/DTO/UserResponse.cs | 1 - rag-2-backend/Models/Entity/User.cs | 17 ++--- .../{Utils => Models}/MailSettings.cs | 0 rag-2-backend/Models/Role.cs | 2 +- rag-2-backend/Program.cs | 18 ++--- .../Services/BackgroundServiceImpl.cs | 36 ++++----- rag-2-backend/Services/GameRecordService.cs | 3 +- rag-2-backend/Services/GameService.cs | 6 +- rag-2-backend/Services/UserService.cs | 71 ++++++++---------- rag-2-backend/Test/GameRecordServiceTest.cs | 34 ++++----- rag-2-backend/Test/GameServiceTest.cs | 13 ++-- rag-2-backend/Test/UserServiceTest.cs | 30 ++++---- rag-2-backend/Utils/JwtUtil.cs | 6 +- rag-2-backend/Utils/TokenGenerationUtil.cs | 5 +- rag-2-backend/appsettings.json | 4 +- rag-2-backend/rag-2-backend.csproj | 50 ++++++------- 25 files changed, 199 insertions(+), 224 deletions(-) rename rag-2-backend/{Data => Config}/DatabaseContext.cs (100%) rename rag-2-backend/{Utils => Config}/ExceptionHandlingMiddleware.cs (96%) rename rag-2-backend/{Utils => Models}/MailSettings.cs (100%) diff --git a/rag-2-backend/Data/DatabaseContext.cs b/rag-2-backend/Config/DatabaseContext.cs similarity index 100% rename from rag-2-backend/Data/DatabaseContext.cs rename to rag-2-backend/Config/DatabaseContext.cs diff --git a/rag-2-backend/Utils/ExceptionHandlingMiddleware.cs b/rag-2-backend/Config/ExceptionHandlingMiddleware.cs similarity index 96% rename from rag-2-backend/Utils/ExceptionHandlingMiddleware.cs rename to rag-2-backend/Config/ExceptionHandlingMiddleware.cs index d01a52a..f6f3976 100644 --- a/rag-2-backend/Utils/ExceptionHandlingMiddleware.cs +++ b/rag-2-backend/Config/ExceptionHandlingMiddleware.cs @@ -6,8 +6,8 @@ public record ExceptionResponse(HttpStatusCode StatusCode, string Description); public class ExceptionHandlingMiddleware { - private readonly RequestDelegate _next; private readonly ILogger _logger; + private readonly RequestDelegate _next; public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) { @@ -31,7 +31,7 @@ private async Task HandleExceptionAsync(HttpContext context, Exception exception { _logger.LogError(exception, "An unexpected error occurred."); - ExceptionResponse response = exception switch + var response = exception switch { BadHttpRequestException e => new ExceptionResponse(HttpStatusCode.BadRequest, e.Message), KeyNotFoundException e => new ExceptionResponse(HttpStatusCode.NotFound, e.Message), diff --git a/rag-2-backend/Controllers/GameController.cs b/rag-2-backend/Controllers/GameController.cs index 979f7e7..ce12c79 100644 --- a/rag-2-backend/Controllers/GameController.cs +++ b/rag-2-backend/Controllers/GameController.cs @@ -17,7 +17,7 @@ public async Task> GetGames() } /// - /// (Admin) + /// (Admin) /// [HttpPost] [Authorize(Roles = "Admin")] @@ -27,7 +27,7 @@ public void Add([FromBody] [Required] GameRequest request) } /// - /// (Admin) + /// (Admin) /// [HttpPut("{id:int}")] [Authorize(Roles = "Admin")] @@ -37,7 +37,7 @@ public void Edit([FromBody] [Required] GameRequest request, int id) } /// - /// (Admin) + /// (Admin) /// [HttpDelete("{id:int}")] [Authorize(Roles = "Admin")] diff --git a/rag-2-backend/Controllers/GameRecordController.cs b/rag-2-backend/Controllers/GameRecordController.cs index e161be4..e31dd6e 100644 --- a/rag-2-backend/Controllers/GameRecordController.cs +++ b/rag-2-backend/Controllers/GameRecordController.cs @@ -12,13 +12,13 @@ namespace rag_2_backend.controllers; public class GameRecordController(GameRecordService gameRecordService) : ControllerBase { [HttpGet] - public async Task> GetRecordsByGame([Required] int gameId) + public List GetRecordsByGame([Required] int gameId) { - return await gameRecordService.GetRecordsByGame(gameId); + return gameRecordService.GetRecordsByGame(gameId); } /// - /// (Authenticated) + /// (Authenticated) /// [HttpPost] [Authorize] diff --git a/rag-2-backend/Controllers/UserController.cs b/rag-2-backend/Controllers/UserController.cs index 15bed7f..ee9637b 100644 --- a/rag-2-backend/Controllers/UserController.cs +++ b/rag-2-backend/Controllers/UserController.cs @@ -1,77 +1,81 @@ using System.ComponentModel.DataAnnotations; -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using rag_2_backend.DTO; using rag_2_backend.Services; -using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; namespace rag_2_backend.controllers; [ApiController] -[Route("api/[controller]")] +[Route("api/[controller]/auth")] public class UserController(UserService userService) : ControllerBase { - [HttpPost("auth/register")] + [HttpPost("register")] public void Register([FromBody] [Required] UserRequest userRequest) { userService.RegisterUser(userRequest); } - [HttpPost("auth/login")] - public async Task Login([FromBody] [Required] UserLoginRequest loginRequest) + [HttpPost("login")] + public string Login([FromBody] [Required] UserLoginRequest loginRequest) { - return await userService.LoginUser(loginRequest.Email, loginRequest.Password); + return userService.LoginUser(loginRequest.Email, loginRequest.Password); } - [HttpPost("auth/logout")] - [Authorize] - public void Logout() - { - var header = HttpContext.Request.Headers.Authorization.FirstOrDefault() ?? - throw new UnauthorizedAccessException("Unauthorized"); - - userService.LogoutUser(header); - } - - [HttpPost("auth/resend-confirmation-email")] + [HttpPost("resend-confirmation-email")] public void ResendConfirmationEmail([Required] string email) { userService.ResendConfirmationEmail(email); } - [HttpPost("auth/confirm-account")] + [HttpPost("confirm-account")] public void ConfirmAccount([Required] string token) { userService.ConfirmAccount(token); } + [HttpPost("request-password-reset")] + public void RequestPasswordReset([Required] string email) + { + userService.RequestPasswordReset(email); + } + + [HttpPost("reset-password")] + public void ResetPassword([Required] string tokenValue, [Required] string newPassword) + { + userService.ResetPassword(tokenValue, newPassword); + } + /// - /// (Auth) + /// (Auth) /// - [HttpGet("auth/me")] + [HttpPost("logout")] [Authorize] - public async Task Me() + public void Logout() { - var email = User.FindFirst(ClaimTypes.Email)?.Value ?? throw new UnauthorizedAccessException("Unauthorized"); + var header = HttpContext.Request.Headers.Authorization.FirstOrDefault() ?? + throw new UnauthorizedAccessException("Unauthorized"); - return await userService.GetMe(email); + userService.LogoutUser(header); } - [HttpPost("auth/request-password-reset")] - public void RequestPasswordReset([Required] string email) + /// + /// (Auth) + /// + [HttpGet("me")] + [Authorize] + public UserResponse Me() { - userService.RequestPasswordReset(email); - } + var email = User.FindFirst(ClaimTypes.Email)?.Value ?? throw new UnauthorizedAccessException("Unauthorized"); - [HttpPost("auth/reset-password")] - public void ResetPassword([Required] string tokenValue, [Required] string newPassword) - { - userService.ResetPassword(tokenValue, newPassword); + return userService.GetMe(email); } - [HttpPost("auth/change-password")] + /// + /// (Auth) + /// + [HttpPost("change-password")] [Authorize] public void ChangePassword([Required] string oldPassword, [Required] string newPassword) { @@ -80,7 +84,10 @@ public void ChangePassword([Required] string oldPassword, [Required] string newP userService.ChangePassword(email, oldPassword, newPassword); } - [HttpPost("auth/delete-account")] + /// + /// (Auth) + /// + [HttpPost("delete-account")] [Authorize] public void DeleteAccount() { diff --git a/rag-2-backend/DTO/GameResponse.cs b/rag-2-backend/DTO/GameResponse.cs index 5c6e39e..e338bfd 100644 --- a/rag-2-backend/DTO/GameResponse.cs +++ b/rag-2-backend/DTO/GameResponse.cs @@ -1,11 +1,10 @@ using rag_2_backend.Models; -namespace rag_2_backend.DTO +namespace rag_2_backend.DTO; + +public class GameResponse { - public class GameResponse - { - public int Id { get; init; } - public required string Name { get; init; } - public GameType GameType { get; init; } - } + public int Id { get; init; } + public required string Name { get; init; } + public GameType GameType { get; init; } } \ No newline at end of file diff --git a/rag-2-backend/DTO/Mapper/GameMapper.cs b/rag-2-backend/DTO/Mapper/GameMapper.cs index cb230ce..0502281 100644 --- a/rag-2-backend/DTO/Mapper/GameMapper.cs +++ b/rag-2-backend/DTO/Mapper/GameMapper.cs @@ -1,9 +1,8 @@ -namespace rag_2_backend.DTO.Mapper; - -using rag_2_backend.DTO; using rag_2_backend.models.entity; -public class GameMapper +namespace rag_2_backend.DTO.Mapper; + +public abstract class GameMapper { public static GameResponse Map(Game game) { diff --git a/rag-2-backend/DTO/Mapper/RecordedGameMapper.cs b/rag-2-backend/DTO/Mapper/RecordedGameMapper.cs index ca71067..0240c75 100644 --- a/rag-2-backend/DTO/Mapper/RecordedGameMapper.cs +++ b/rag-2-backend/DTO/Mapper/RecordedGameMapper.cs @@ -1,9 +1,8 @@ -namespace rag_2_backend.DTO.Mapper; - -using rag_2_backend.DTO; using rag_2_backend.models.entity; -public class RecordedGameMapper +namespace rag_2_backend.DTO.Mapper; + +public abstract class RecordedGameMapper { public static RecordedGameResponse Map(RecordedGame recordedGame) { diff --git a/rag-2-backend/DTO/Mapper/UserMapper.cs b/rag-2-backend/DTO/Mapper/UserMapper.cs index c92d432..1cfecbf 100644 --- a/rag-2-backend/DTO/Mapper/UserMapper.cs +++ b/rag-2-backend/DTO/Mapper/UserMapper.cs @@ -1,9 +1,8 @@ -namespace rag_2_backend.DTO.Mapper; - -using rag_2_backend.DTO; using rag_2_backend.Models.Entity; -public class UserMapper +namespace rag_2_backend.DTO.Mapper; + +public abstract class UserMapper { public static UserResponse Map(User user) { @@ -13,7 +12,7 @@ public static UserResponse Map(User user) Email = user.Email, Role = user.Role, StudyCycleYearA = user.StudyCycleYearA, - StudyCycleYearB = user.StudyCycleYearB, + StudyCycleYearB = user.StudyCycleYearB }; } } \ No newline at end of file diff --git a/rag-2-backend/DTO/UserResponse.cs b/rag-2-backend/DTO/UserResponse.cs index 78567d5..35982b2 100644 --- a/rag-2-backend/DTO/UserResponse.cs +++ b/rag-2-backend/DTO/UserResponse.cs @@ -1,6 +1,5 @@ using System.ComponentModel.DataAnnotations; using rag_2_backend.Models; -using rag_2_backend.Models.Entity; namespace rag_2_backend.DTO; diff --git a/rag-2-backend/Models/Entity/User.cs b/rag-2-backend/Models/Entity/User.cs index 97683fb..c2ff56f 100644 --- a/rag-2-backend/Models/Entity/User.cs +++ b/rag-2-backend/Models/Entity/User.cs @@ -1,20 +1,11 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using rag_2_backend.Services; namespace rag_2_backend.Models.Entity; [Table("users")] public class User { - [Key] public int Id { get; init; } - [MaxLength(100)] public string Email { get; init; } = ""; - [MaxLength(100)] public required string Password { get; set; } - public Role Role { get; set; } - public bool Confirmed { get; set; } - public int StudyCycleYearA { get; init; } - public int StudyCycleYearB { get; init; } - public User() //for ef { } @@ -29,4 +20,12 @@ public User(string email) Role = domain.Equals("stud.prz.edu.pl") ? Role.Student : Role.Teacher; Email = email; } + + [Key] public int Id { get; init; } + [MaxLength(100)] public string Email { get; init; } = ""; + [MaxLength(100)] public required string Password { get; set; } + public Role Role { get; set; } + public bool Confirmed { get; set; } + public int StudyCycleYearA { get; init; } + public int StudyCycleYearB { get; init; } } \ No newline at end of file diff --git a/rag-2-backend/Utils/MailSettings.cs b/rag-2-backend/Models/MailSettings.cs similarity index 100% rename from rag-2-backend/Utils/MailSettings.cs rename to rag-2-backend/Models/MailSettings.cs diff --git a/rag-2-backend/Models/Role.cs b/rag-2-backend/Models/Role.cs index cb199a3..79abe9c 100644 --- a/rag-2-backend/Models/Role.cs +++ b/rag-2-backend/Models/Role.cs @@ -5,5 +5,5 @@ public enum Role Student, Teacher, Special, - Admin, + Admin } \ No newline at end of file diff --git a/rag-2-backend/Program.cs b/rag-2-backend/Program.cs index cb48422..67ee13d 100644 --- a/rag-2-backend/Program.cs +++ b/rag-2-backend/Program.cs @@ -1,14 +1,14 @@ using System.IdentityModel.Tokens.Jwt; -using Microsoft.EntityFrameworkCore; +using System.Text; using System.Text.Json.Serialization; -using rag_2_backend.data; -using rag_2_backend.Exceptions; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; -using System.Text; using Microsoft.OpenApi.Models; -using rag_2_backend.Utils; +using rag_2_backend.data; +using rag_2_backend.Exceptions; using rag_2_backend.Services; +using rag_2_backend.Utils; var builder = WebApplication.CreateBuilder(args); @@ -41,9 +41,7 @@ var token = header["Bearer ".Length..].Trim(); if (!string.IsNullOrEmpty(token) && await tokenBlacklistService.IsTokenBlacklistedAsync(token)) - { throw new UnauthorizedAccessException("Token is not valid"); - } } }; }); @@ -52,7 +50,7 @@ builder.Services.AddSwaggerGen(options => { - options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme() + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { Name = "Authorization", In = ParameterLocation.Header, @@ -88,8 +86,8 @@ }); builder.Services.AddCors(options => { - options.AddDefaultPolicy(builder => - builder.WithOrigins("http://localhost:4200").AllowAnyMethod().AllowAnyHeader().AllowCredentials()); + options.AddDefaultPolicy(b => + b.WithOrigins("http://localhost:4200").AllowAnyMethod().AllowAnyHeader().AllowCredentials()); }); builder.Services.Configure(builder.Configuration.GetSection("MailSettings")); diff --git a/rag-2-backend/Services/BackgroundServiceImpl.cs b/rag-2-backend/Services/BackgroundServiceImpl.cs index 5854936..6fe1dad 100644 --- a/rag-2-backend/Services/BackgroundServiceImpl.cs +++ b/rag-2-backend/Services/BackgroundServiceImpl.cs @@ -6,8 +6,13 @@ namespace rag_2_backend.Services; public class BackgroundServiceImpl(IServiceProvider serviceProvider) : BackgroundService { + private DatabaseContext _dbContext; + protected override async Task ExecuteAsync(CancellationToken cancellationToken) { + using var scope = serviceProvider.CreateScope(); + _dbContext = scope.ServiceProvider.GetRequiredService(); + while (!cancellationToken.IsCancellationRequested) { DeleteUnusedAccountTokens(); @@ -20,45 +25,36 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) private void DeleteUnusedAccountTokens() { - using var scope = serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var unconfirmedUsers = new List(); - var unusedTokens = dbContext.AccountConfirmationTokens + var unusedTokens = _dbContext.AccountConfirmationTokens .Include(t => t.User) .Where(t => t.Expiration < DateTime.Now).ToList(); foreach (var users in unusedTokens.Select(token => - new List(dbContext.Users.Where(u => u.Email == token.User.Email && !u.Confirmed)))) + new List(_dbContext.Users.Where(u => u.Email == token.User.Email && !u.Confirmed)))) unconfirmedUsers.AddRange(users); - dbContext.Users.RemoveRange(unconfirmedUsers); - dbContext.AccountConfirmationTokens.RemoveRange(unusedTokens); - dbContext.SaveChanges(); + _dbContext.Users.RemoveRange(unconfirmedUsers); + _dbContext.AccountConfirmationTokens.RemoveRange(unusedTokens); + _dbContext.SaveChanges(); Console.WriteLine("Deleted " + unconfirmedUsers.Count + " unconfirmed accounts"); } private void DeleteUnusedBlacklistedJwts() { - using var scope = serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var unusedTokens = dbContext.BlacklistedJwts.Where(b => b.Expiration < DateTime.Now).ToList(); - dbContext.BlacklistedJwts.RemoveRange(unusedTokens); - dbContext.SaveChanges(); + var unusedTokens = _dbContext.BlacklistedJwts.Where(b => b.Expiration < DateTime.Now).ToList(); + _dbContext.BlacklistedJwts.RemoveRange(unusedTokens); + _dbContext.SaveChanges(); Console.WriteLine("Deleted" + unusedTokens.Count + " blacklisted jwts"); } private void DeleteUnusedPasswordResetTokens() { - using var scope = serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var unusedTokens = dbContext.PasswordResetTokens.Where(b => b.Expiration < DateTime.Now).ToList(); - dbContext.PasswordResetTokens.RemoveRange(unusedTokens); - dbContext.SaveChanges(); + var unusedTokens = _dbContext.PasswordResetTokens.Where(b => b.Expiration < DateTime.Now).ToList(); + _dbContext.PasswordResetTokens.RemoveRange(unusedTokens); + _dbContext.SaveChanges(); Console.WriteLine("Deleted" + unusedTokens.Count + " password reset tokens"); } diff --git a/rag-2-backend/Services/GameRecordService.cs b/rag-2-backend/Services/GameRecordService.cs index 290ee69..d28ab6f 100644 --- a/rag-2-backend/Services/GameRecordService.cs +++ b/rag-2-backend/Services/GameRecordService.cs @@ -8,9 +8,8 @@ namespace rag_2_backend.Services; public class GameRecordService(DatabaseContext context) { - public async Task> GetRecordsByGame(int gameId) + public List GetRecordsByGame(int gameId) { - var games = await context.RecordedGames.ToArrayAsync(); var records = context.RecordedGames .Include(r => r.Game) //include nullable reference .Include(r => r.User) diff --git a/rag-2-backend/Services/GameService.cs b/rag-2-backend/Services/GameService.cs index 8f9f07d..6f30bf3 100644 --- a/rag-2-backend/Services/GameService.cs +++ b/rag-2-backend/Services/GameService.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Server.IIS; using Microsoft.EntityFrameworkCore; using rag_2_backend.data; using rag_2_backend.DTO; @@ -55,10 +54,7 @@ public void RemoveGame(int id) var game = context.Games.SingleOrDefault(g => g.Id == id) ?? throw new KeyNotFoundException("Game not found"); var records = context.RecordedGames.Where(g => g.Game.Id == id).ToList(); - foreach (var record in records) - { - context.RecordedGames.Remove(record); - } + foreach (var record in records) context.RecordedGames.Remove(record); context.Games.Remove(game); context.SaveChanges(); diff --git a/rag-2-backend/Services/UserService.cs b/rag-2-backend/Services/UserService.cs index 656ab95..32dae46 100644 --- a/rag-2-backend/Services/UserService.cs +++ b/rag-2-backend/Services/UserService.cs @@ -24,27 +24,20 @@ public void RegisterUser(UserRequest userRequest) { Password = HashUtil.HashPassword(userRequest.Password), StudyCycleYearA = userRequest.StudyCycleYearA, - StudyCycleYearB = userRequest.StudyCycleYearB, + StudyCycleYearB = userRequest.StudyCycleYearB }; - if (user.Role == Role.Student) - { - if (userRequest.StudyCycleYearA == 0 || userRequest.StudyCycleYearB == 0 || - userRequest.StudyCycleYearB - userRequest.StudyCycleYearA != 1) - throw new BadHttpRequestException("Wrong study cycle year"); - } - context.Users.Add(user); + if (user.Role == Role.Student && IsStudyYearWrong(userRequest)) + throw new BadHttpRequestException("Wrong study cycle year"); + context.Users.Add(user); GenerateAccountTokenAndSendConfirmationMail(user); - context.SaveChanges(); } public void ResendConfirmationEmail(string email) { - var user = context.Users.SingleOrDefault(u => u.Email == email) ?? - throw new KeyNotFoundException("User not found"); - + var user = GetUserByEmailOrThrow(email); if (user.Confirmed) throw new BadHttpRequestException("User is already confirmed"); context.AccountConfirmationTokens.RemoveRange( @@ -52,7 +45,6 @@ public void ResendConfirmationEmail(string email) ); GenerateAccountTokenAndSendConfirmationMail(user); - context.SaveChanges(); } @@ -62,20 +54,17 @@ public void ConfirmAccount(string tokenValue) .Include(t => t.User) .SingleOrDefault(t => t.Token == tokenValue) ?? throw new BadHttpRequestException("Invalid token"); - if (token.Expiration < DateTime.Now) throw new BadHttpRequestException("Invalid token"); - var user = context.Users.SingleOrDefault(u => u.Email == token.User.Email) ?? - throw new KeyNotFoundException("User not found"); - user.Confirmed = true; + if (token.Expiration < DateTime.Now) throw new BadHttpRequestException("Invalid token"); + token.User.Confirmed = true; context.AccountConfirmationTokens.Remove(token); context.SaveChanges(); } - public async Task LoginUser(string email, string password) + public string LoginUser(string email, string password) { - var user = await context.Users.FirstOrDefaultAsync(u => u.Email == email) ?? - throw new KeyNotFoundException("User not found"); + var user = GetUserByEmailOrThrow(email); if (!HashUtil.VerifyPassword(password, user.Password)) throw new UnauthorizedAccessException("Invalid password"); @@ -85,38 +74,30 @@ public async Task LoginUser(string email, string password) return jwtUtil.GenerateToken(user.Email, user.Role.ToString()); } - public async Task GetMe(string email) + public UserResponse GetMe(string email) { - var user = await context.Users.FirstOrDefaultAsync(u => u.Email == email) ?? - throw new KeyNotFoundException("User not found"); - - return UserMapper.Map(user); + return UserMapper.Map(GetUserByEmailOrThrow(email)); } public void LogoutUser(string header) { var tokenValue = header["Bearer ".Length..].Trim(); - var jwtToken = jwtSecurityTokenHandler.ReadToken(tokenValue) as JwtSecurityToken ?? throw new UnauthorizedAccessException("Unauthorized"); - var expiryDate = jwtToken.ValidTo; - context.BlacklistedJwts.Add(new BlacklistedJwt { Token = tokenValue, Expiration = expiryDate }); - + context.BlacklistedJwts.Add(new BlacklistedJwt { Token = tokenValue, Expiration = jwtToken.ValidTo }); context.SaveChanges(); } public void RequestPasswordReset(string email) { - var user = context.Users.SingleOrDefault(u => u.Email == email) ?? - throw new KeyNotFoundException("User not found"); + var user = GetUserByEmailOrThrow(email); context.PasswordResetTokens.RemoveRange( context.PasswordResetTokens.Where(a => a.User.Email == user.Email) ); GeneratePasswordResetTokenAndSendMail(user); - context.SaveChanges(); } @@ -126,20 +107,17 @@ public void ResetPassword(string tokenValue, string newPassword) .Include(t => t.User) .SingleOrDefault(t => t.Token == tokenValue) ?? throw new BadHttpRequestException("Invalid token"); - if (token.Expiration < DateTime.Now) throw new BadHttpRequestException("Invalid token"); - var user = context.Users.SingleOrDefault(u => u.Email == token.User.Email) ?? - throw new KeyNotFoundException("User not found"); - user.Password = HashUtil.HashPassword(newPassword); + if (token.Expiration < DateTime.Now) throw new BadHttpRequestException("Invalid token"); + token.User.Password = HashUtil.HashPassword(newPassword); context.PasswordResetTokens.Remove(token); context.SaveChanges(); } public void ChangePassword(string email, string oldPassword, string newPassword) { - var user = context.Users.SingleOrDefault(u => u.Email == email) ?? - throw new KeyNotFoundException("User not found"); + var user = GetUserByEmailOrThrow(email); if (!HashUtil.VerifyPassword(oldPassword, user.Password)) throw new BadHttpRequestException("Invalid old password"); @@ -152,8 +130,7 @@ public void ChangePassword(string email, string oldPassword, string newPassword) public void DeleteAccount(string email, string header) { - var user = context.Users.SingleOrDefault(u => u.Email == email) ?? - throw new KeyNotFoundException("User not found"); + var user = GetUserByEmailOrThrow(email); context.PasswordResetTokens.RemoveRange(context.PasswordResetTokens .Include(p => p.User) @@ -176,6 +153,18 @@ public void DeleteAccount(string email, string header) // + private User GetUserByEmailOrThrow(string email) + { + return context.Users.SingleOrDefault(u => u.Email == email) ?? + throw new KeyNotFoundException("User not found"); + } + + private static bool IsStudyYearWrong(UserRequest userRequest) + { + return userRequest.StudyCycleYearA == 0 || userRequest.StudyCycleYearB == 0 || + userRequest.StudyCycleYearB - userRequest.StudyCycleYearA != 1; + } + private void GenerateAccountTokenAndSendConfirmationMail(User user) { var token = new AccountConfirmationToken @@ -194,7 +183,7 @@ private void GeneratePasswordResetTokenAndSendMail(User user) { Token = TokenGenerationUtil.GenerateToken(15), User = user, - Expiration = DateTime.Now.AddDays(7) + Expiration = DateTime.Now.AddDays(2) }; context.PasswordResetTokens.Add(token); emailService.SendPasswordResetMail(user.Email, token.Token); diff --git a/rag-2-backend/Test/GameRecordServiceTest.cs b/rag-2-backend/Test/GameRecordServiceTest.cs index b40b664..437e077 100644 --- a/rag-2-backend/Test/GameRecordServiceTest.cs +++ b/rag-2-backend/Test/GameRecordServiceTest.cs @@ -1,14 +1,14 @@ -using Moq; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; using MockQueryable.Moq; +using Moq; using rag_2_backend.data; using rag_2_backend.DTO; -using rag_2_backend.models.entity; using rag_2_backend.Models; +using rag_2_backend.models.entity; using rag_2_backend.Models.Entity; using rag_2_backend.Services; using Xunit; -using Microsoft.EntityFrameworkCore; -using System.Text.Json; namespace rag_2_backend.Test; @@ -18,8 +18,16 @@ public class GameRecordServiceTest new DbContextOptionsBuilder().Options ); + private readonly Game _game = new() + { + Id = 1, + Name = "Game1" + }; + private readonly GameRecordService _gameRecordService; + private readonly List _recordedGames = []; + private readonly User _user = new("email@prz.edu.pl") { Id = 1, @@ -28,14 +36,6 @@ public class GameRecordServiceTest StudyCycleYearB = 2023 }; - private readonly Game _game = new() - { - Id = 1, - Name = "Game1", - }; - - private readonly List _recordedGames = []; - public GameRecordServiceTest() { _contextMock.Setup(c => c.RecordedGames).Returns( @@ -55,17 +55,17 @@ public GameRecordServiceTest() Id = 1, Game = _game, Value = "10", - User = _user, + User = _user }); } [Fact] public async void GetRecordsByGameTest() { - var actualRecords = await _gameRecordService.GetRecordsByGame(1); + var actualRecords = _gameRecordService.GetRecordsByGame(1); List expectedRecords = [ - new() + new RecordedGameResponse { Id = 1, Value = "10", @@ -74,8 +74,8 @@ public async void GetRecordsByGameTest() { Id = 1, Email = "email@prz.edu.pl", Role = Role.Teacher, StudyCycleYearA = 2022, StudyCycleYearB = 2023 - }, - }, + } + } ]; Assert.Equal(expectedRecords.Count, actualRecords.Count); diff --git a/rag-2-backend/Test/GameServiceTest.cs b/rag-2-backend/Test/GameServiceTest.cs index ea2e9bc..75ee6a6 100644 --- a/rag-2-backend/Test/GameServiceTest.cs +++ b/rag-2-backend/Test/GameServiceTest.cs @@ -1,14 +1,13 @@ using Microsoft.EntityFrameworkCore; using MockQueryable.Moq; using Moq; +using Newtonsoft.Json; using rag_2_backend.data; using rag_2_backend.DTO; using rag_2_backend.DTO.Mapper; -using rag_2_backend.models.entity; using rag_2_backend.Models; +using rag_2_backend.models.entity; using rag_2_backend.Services; -using Newtonsoft.Json; -using rag_2_backend.Models.Entity; using Xunit; namespace rag_2_backend.Test; @@ -19,14 +18,14 @@ public class GameServiceTest new DbContextOptionsBuilder().Options ); - private readonly GameService _gameService; - private readonly List _games = [ - new() { Id = 1, Name = "Game1", GameType = GameType.EventGame }, - new() { Id = 2, Name = "Game2", GameType = GameType.TimeGame }, + new Game { Id = 1, Name = "Game1", GameType = GameType.EventGame }, + new Game { Id = 2, Name = "Game2", GameType = GameType.TimeGame } ]; + private readonly GameService _gameService; + public GameServiceTest() { _gameService = new GameService(_contextMock.Object); diff --git a/rag-2-backend/Test/UserServiceTest.cs b/rag-2-backend/Test/UserServiceTest.cs index ce0379c..98decdd 100644 --- a/rag-2-backend/Test/UserServiceTest.cs +++ b/rag-2-backend/Test/UserServiceTest.cs @@ -16,14 +16,17 @@ namespace rag_2_backend.Test; public class UserServiceTest { + private readonly AccountConfirmationToken _accountToken; + private readonly Mock _contextMock = new( new DbContextOptionsBuilder().Options ); - private readonly Mock _jwtUtilMock = new(null, null); - private readonly Mock _jwtSecurityTokenHandlerMock = new(); private readonly Mock _emailService = new(null, null); - private readonly UserService _userService; + private readonly Mock _jwtSecurityTokenHandlerMock = new(); + + private readonly Mock _jwtUtilMock = new(null, null); + private readonly PasswordResetToken _passwordToken; private readonly User _user = new("email@prz.edu.pl") { @@ -33,8 +36,7 @@ public class UserServiceTest StudyCycleYearB = 2023 }; - private readonly AccountConfirmationToken _accountToken; - private readonly PasswordResetToken _passwordToken; + private readonly UserService _userService; public UserServiceTest() { @@ -42,13 +44,13 @@ public UserServiceTest() { User = _user, Expiration = DateTime.Now.AddDays(7), - Token = HashUtil.HashPassword("password"), + Token = HashUtil.HashPassword("password") }; - _passwordToken = new PasswordResetToken() + _passwordToken = new PasswordResetToken { User = _user, Expiration = DateTime.Now.AddDays(7), - Token = HashUtil.HashPassword("password"), + Token = HashUtil.HashPassword("password") }; _userService = new UserService(_contextMock.Object, _jwtUtilMock.Object, _emailService.Object, _jwtSecurityTokenHandlerMock.Object); @@ -62,7 +64,7 @@ public UserServiceTest() _contextMock.Setup(c => c.RecordedGames) .Returns(() => new List().AsQueryable().BuildMockDbSet().Object); _contextMock.Setup(c => c.PasswordResetTokens) - .Returns(() => new List() { _passwordToken }.AsQueryable().BuildMockDbSet().Object); + .Returns(() => new List { _passwordToken }.AsQueryable().BuildMockDbSet().Object); _jwtUtilMock.Setup(j => j.GenerateToken(It.IsAny(), It.IsAny())).Verifiable(); _emailService.Setup(e => e.SendConfirmationEmail(It.IsAny(), It.IsAny())).Verifiable(); _emailService.Setup(e => e.SendPasswordResetMail(It.IsAny(), It.IsAny())).Verifiable(); @@ -113,16 +115,16 @@ public void ShouldConfirmAccount() [Fact] public async void ShouldLoginUser() { - await Assert.ThrowsAsync( + Assert.Throws( () => _userService.LoginUser("email@prz.edu.pl", "pass") ); //wrong password - await Assert.ThrowsAsync( + Assert.Throws( () => _userService.LoginUser("email@prz.edu.pl", "password") ); //not confirmed _user.Confirmed = true; - await _userService.LoginUser("email@prz.edu.pl", "password"); + _userService.LoginUser("email@prz.edu.pl", "password"); _jwtUtilMock.Verify(j => j.GenerateToken(It.IsAny(), It.IsAny()), Times.Once); } @@ -137,7 +139,7 @@ public void ShouldLogoutUser() [Fact] public void ShouldGetMe() { - var userResponse = new UserResponse() + var userResponse = new UserResponse { Id = 1, Email = "email@prz.edu.pl", @@ -147,7 +149,7 @@ public void ShouldGetMe() }; Assert.Equal(JsonConvert.SerializeObject(userResponse), - JsonConvert.SerializeObject(_userService.GetMe("email@prz.edu.pl").Result)); + JsonConvert.SerializeObject(_userService.GetMe("email@prz.edu.pl"))); } [Fact] diff --git a/rag-2-backend/Utils/JwtUtil.cs b/rag-2-backend/Utils/JwtUtil.cs index 8e524f7..6ad5b14 100644 --- a/rag-2-backend/Utils/JwtUtil.cs +++ b/rag-2-backend/Utils/JwtUtil.cs @@ -23,9 +23,9 @@ public virtual string GenerateToken(string email, string role) ]; var token = new JwtSecurityToken( - issuer: config["Jwt:Issuer"], - audience: config["Jwt:Issuer"], - claims: claims, + config["Jwt:Issuer"], + config["Jwt:Issuer"], + claims, expires: DateTime.Now.AddMinutes(double.Parse(config["Jwt:ExpireMinutes"] ?? "60")), signingCredentials: credentials ); diff --git a/rag-2-backend/Utils/TokenGenerationUtil.cs b/rag-2-backend/Utils/TokenGenerationUtil.cs index 53c729a..9ac9b62 100644 --- a/rag-2-backend/Utils/TokenGenerationUtil.cs +++ b/rag-2-backend/Utils/TokenGenerationUtil.cs @@ -9,10 +9,7 @@ public static string GenerateToken(int length) const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; var res = new StringBuilder(); var rnd = new Random(); - while (0 < length--) - { - res.Append(valid[rnd.Next(valid.Length)]); - } + while (0 < length--) res.Append(valid[rnd.Next(valid.Length)]); return res.ToString(); } diff --git a/rag-2-backend/appsettings.json b/rag-2-backend/appsettings.json index 2623afa..6d470a1 100644 --- a/rag-2-backend/appsettings.json +++ b/rag-2-backend/appsettings.json @@ -14,8 +14,7 @@ "Issuer": "immortalcoders.com", "ExpireMinutes": 60 }, - "MailSettings": - { + "MailSettings": { "Host": "smtp.gmail.com", "DefaultCredentials": false, "Port": 587, @@ -25,5 +24,4 @@ "Password": "fzozhrthfueuuorm", "UseSSL": false } - } diff --git a/rag-2-backend/rag-2-backend.csproj b/rag-2-backend/rag-2-backend.csproj index 2e0f9fd..7221074 100644 --- a/rag-2-backend/rag-2-backend.csproj +++ b/rag-2-backend/rag-2-backend.csproj @@ -11,36 +11,36 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - - + + + + + + + - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + +