From 432f32e0626fa38d0f83c7c4357cda552c800c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 3 Sep 2024 20:52:56 +0200 Subject: [PATCH 01/37] Add Token entity and sketch controller --- .../CoffeeCard.Models/Entities/Token.cs | 14 ++++---- .../CoffeeCard.Models/Entities/TokenType.cs | 27 ++++++++++++++ .../Controllers/v2/AccountController.cs | 36 +++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 coffeecard/CoffeeCard.Models/Entities/TokenType.cs diff --git a/coffeecard/CoffeeCard.Models/Entities/Token.cs b/coffeecard/CoffeeCard.Models/Entities/Token.cs index 77d9cbf9..575086cb 100644 --- a/coffeecard/CoffeeCard.Models/Entities/Token.cs +++ b/coffeecard/CoffeeCard.Models/Entities/Token.cs @@ -3,22 +3,22 @@ namespace CoffeeCard.Models.Entities { - public class Token + public class Token(string tokenHash, TokenType type, DateTime expiresAt) { - public Token(string tokenHash) - { - TokenHash = tokenHash; - } - public int Id { get; set; } - public string TokenHash { get; set; } + public string TokenHash { get; set; } = tokenHash; [Column(name: "User_Id")] public int? UserId { get; set; } public User? User { get; set; } + public TokenType Type { get; set; } = type; + + public DateTime Expires { get; set; } = expiresAt; + + public override bool Equals(object? obj) { if (obj is Token newToken) return TokenHash.Equals(newToken.TokenHash); diff --git a/coffeecard/CoffeeCard.Models/Entities/TokenType.cs b/coffeecard/CoffeeCard.Models/Entities/TokenType.cs new file mode 100644 index 00000000..f4ae79f5 --- /dev/null +++ b/coffeecard/CoffeeCard.Models/Entities/TokenType.cs @@ -0,0 +1,27 @@ +using System; + +namespace CoffeeCard.Models.Entities +{ + public enum TokenType + { + ResetPassword, + DeleteAccount, + MagicLink, + Refresh + } + + public static class TokenTypeExtensions + { + public static DateTime getExpiresAt(this TokenType tokenType) + { + return tokenType switch + { + TokenType.ResetPassword => DateTime.UtcNow.AddDays(1), + TokenType.DeleteAccount => DateTime.UtcNow.AddDays(1), + TokenType.MagicLink => DateTime.UtcNow.AddMinutes(30), + TokenType.Refresh => DateTime.UtcNow.AddMonths(1), + _ => DateTime.UtcNow.AddDays(1) + }; + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index 351639bf..bc214047 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -14,6 +14,7 @@ using CoffeeCard.Models.Entities; using CoffeeCard.WebApi.Helpers; using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Identity.Data; namespace CoffeeCard.WebApi.Controllers.v2 { @@ -224,5 +225,40 @@ public async Task> SearchUsers([FromQuery][Rang { return Ok(await _accountService.SearchUsers(filter, pageNum, pageLength)); } + + [HttpPost] + [AllowAnonymous] + [Route("login")] + public async Task> Login([FromBody] string email) + { + // Generate magic link token (MLT) + var guid = Guid.NewGuid().ToString(); + var magicLink = new Token(guid, TokenType.Refresh, TokenType.Refresh.getExpiresAt()); + + // Store MLT in DB + + // Send MLT using MailGun + + // Return 200 OK + } + + [HttpGet] + [AllowAnonymous] + [Route("auth/token={token}")] + public async Task> AuthToken(string token) + { + // Validate token in DB + + // Invalidate token in DB + + // Generate refresh token + + // Generate JWT token with user claims and refresh token + + // Return JWT token + } + + [HttpPost] + [AuthorizeRoles(UserGroup.Customer] } } \ No newline at end of file From a6738f28f100724b9e640b0803fc249e7b0c4de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 17 Sep 2024 20:06:22 +0200 Subject: [PATCH 02/37] Add logic for magic link login and refresh token --- .../Services/AccountService.cs | 2 +- .../Services/TokenService.cs | 8 +- .../Services/v2/AccountService.cs | 91 +++++++++++++-- .../Services/v2/EmailService.cs | 108 ++++++++++++++++++ .../Services/v2/IAccountService.cs | 2 + .../Services/v2/IEmailService.cs | 12 ++ .../Services/v2/ITokenService.cs | 11 ++ .../Services/v2/TokenService.cs | 44 +++++++ .../CoffeeCard.Models/Entities/Token.cs | 3 + .../Controllers/v2/AccountController.cs | 32 ++---- coffeecard/CoffeeCard.WebApi/Startup.cs | 6 +- .../email_magic_link_login.html | 27 +++++ .../SourceEmails/email_magic_link_login.mjml | 41 +++++++ 13 files changed, 352 insertions(+), 35 deletions(-) create mode 100644 coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs create mode 100644 coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs create mode 100644 coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs create mode 100644 coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs create mode 100644 coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/email_magic_link_login.html create mode 100644 coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/SourceEmails/email_magic_link_login.mjml diff --git a/coffeecard/CoffeeCard.Library/Services/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/AccountService.cs index 71a723b0..140c3ff9 100644 --- a/coffeecard/CoffeeCard.Library/Services/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/AccountService.cs @@ -236,7 +236,7 @@ public async Task ForgotPasswordAsync(string email) new Claim(ClaimTypes.Role, "verification_token") }; var verificationToken = _tokenService.GenerateToken(claims); - user.Tokens.Add(new Token(verificationToken)); + user.Tokens.Add(new Token(verificationToken, TokenType.ResetPassword, TokenType.ResetPassword.getExpiresAt())); _context.SaveChanges(); await _emailService.SendVerificationEmailForLostPwAsync(user, verificationToken); } diff --git a/coffeecard/CoffeeCard.Library/Services/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/TokenService.cs index 581d01e8..8b24c2b8 100644 --- a/coffeecard/CoffeeCard.Library/Services/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/TokenService.cs @@ -7,7 +7,9 @@ using System.Threading.Tasks; using CoffeeCard.Common.Configuration; using CoffeeCard.Common.Errors; +using CoffeeCard.Library.Persistence; using CoffeeCard.Library.Utils; +using CoffeeCard.Models.DataTransferObjects.CoffeeCard; using CoffeeCard.Models.Entities; using Microsoft.AspNetCore.Http; using Microsoft.IdentityModel.Tokens; @@ -19,11 +21,13 @@ public class TokenService : ITokenService { private readonly ClaimsUtilities _claimsUtilities; private readonly IdentitySettings _identitySettings; + private readonly CoffeeCardContext _context; - public TokenService(IdentitySettings identitySettings, ClaimsUtilities claimsUtilities) + public TokenService(IdentitySettings identitySettings, ClaimsUtilities claimsUtilities, CoffeeCardContext context) { _identitySettings = identitySettings; _claimsUtilities = claimsUtilities; + _context = context; } public string GenerateToken(IEnumerable claims) @@ -99,7 +103,7 @@ public async Task ValidateTokenIsUnusedAsync(string tokenString) var user = await _claimsUtilities.ValidateAndReturnUserFromEmailClaimAsync(token.Claims); - if (user.Tokens.Contains(new Token(tokenString))) tokenIsUnused = true; // Tokens are removed from the user on account recovery + if (user.Tokens.Any((e) => e.TokenHash == tokenString)) tokenIsUnused = true; // Tokens are removed from the user on account recovery return ValidateToken(tokenString) && tokenIsUnused; } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index 2e91b90b..d98771cd 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -16,15 +16,26 @@ namespace CoffeeCard.Library.Services.v2 public class AccountService : IAccountService { private readonly CoffeeCardContext _context; - private readonly IEmailService _emailService; + private readonly CoffeeCard.Library.Services.IEmailService _emailService; + private readonly CoffeeCard.Library.Services.v2.IEmailService _emailServiceV2; private readonly IHashService _hashService; - private readonly ITokenService _tokenService; - - public AccountService(CoffeeCardContext context, ITokenService tokenService, IEmailService emailService, IHashService hashService) + private readonly CoffeeCard.Library.Services.ITokenService _tokenService; + private readonly CoffeeCard.Library.Services.v2.ITokenService _tokenServiceV2; + + public AccountService( + CoffeeCardContext context, + CoffeeCard.Library.Services.ITokenService tokenService, + CoffeeCard.Library.Services.IEmailService emailService, + CoffeeCard.Library.Services.v2.IEmailService emailServiceV2, + CoffeeCard.Library.Services.v2.ITokenService tokenServiceV2, + HashService hashService + ) { _context = context; _tokenService = tokenService; _emailService = emailService; + _emailServiceV2 = emailServiceV2; + _tokenServiceV2 = tokenServiceV2; _hashService = hashService; } @@ -36,7 +47,7 @@ public async Task RegisterAccountAsync(string name, string email, string p { Log.Information("Could not register user Name: {name}. Email:{email} already exists", name, email); throw new ApiException($"The email {email} is already being used by another user", - StatusCodes.Status409Conflict); + StatusCodes.Status409Conflict); } var salt = _hashService.GenerateSalt(); @@ -222,9 +233,9 @@ public async Task SearchUsers(String search, int pageNum, in else { query = _context.Users - .Where(u => EF.Functions.Like(u.Id.ToString(), $"%{search}%") || - EF.Functions.Like(u.Name, $"%{search}%") || - EF.Functions.Like(u.Email, $"%{search}%")); + .Where(u => EF.Functions.Like(u.Id.ToString(), $"%{search}%") || + EF.Functions.Like(u.Name, $"%{search}%") || + EF.Functions.Like(u.Email, $"%{search}%")); } var totalUsers = await query.CountAsync(); @@ -289,5 +300,69 @@ await _context.Users .ExecuteUpdateAsync(u => u.SetProperty(u => u.UserGroup, item.UserGroup)); } } + + public async Task SendMagicLinkEmail(string email) + { + var user = await GetAccountByEmailAsync(email); + var magicLinkToken = _tokenServiceV2.GenerateMagicLink(email); + await _emailServiceV2.SendMagicLink(user, magicLinkToken); + } + + public async Task LoginByMagicLink(string token) + { + var foundToken = await GetTokenByMagicLink(token); + if (foundToken.Revoked) + { + await InvalidateTokenChain(foundToken.Id); + throw new ApiException("Token already used", 401); + } + + foundToken.Revoked = true; + await _context.SaveChangesAsync(); + // Validate token in DB + + // Invalidate token in DB + + // Generate refresh token + var refreshToken = await _tokenServiceV2.GenerateRefreshTokenAsync(foundToken.User); + + var claims = new[] + { + new Claim(ClaimTypes.Email, foundToken.User!.Email), + new Claim(ClaimTypes.Name, foundToken.User.Name), + new Claim("UserId", foundToken.User.Id.ToString()), + new Claim(ClaimTypes.Role, foundToken.User.UserGroup.ToString()), + new Claim("RefreshToken", refreshToken) + }; + // Generate JWT token with user claims and refresh token + var jwt = _tokenService.GenerateToken(claims); + + // Return JWT token + return jwt; + } + + private async Task GetTokenByMagicLink(string token) + { + var foundToken = await _context.Tokens + .Include(t => t.User) + .FirstOrDefaultAsync(t => t.TokenHash == token); + if (foundToken?.User == null) + { + throw new ApiException("Invalid token", 401); + } + + return foundToken; + } + + private async Task InvalidateTokenChain(int tokenId) + { + // todo: invalidate all from user instead of recursion + var newerToken = _context.Tokens.FirstOrDefault(t => t.PreviousTokenId == tokenId); + if (newerToken != null) + { + newerToken.Revoked = true; + await InvalidateTokenChain(newerToken.Id); + } + } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs new file mode 100644 index 00000000..68fea50d --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs @@ -0,0 +1,108 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using CoffeeCard.Common.Configuration; +using CoffeeCard.Models.DataTransferObjects.Purchase; +using CoffeeCard.Models.DataTransferObjects.User; +using CoffeeCard.Models.Entities; +using Microsoft.AspNetCore.Hosting; +using MimeKit; +using RestSharp; +using RestSharp.Authenticators; +using Serilog; +using TimeZoneConverter; + +namespace CoffeeCard.Library.Services.v2 +{ + public class EmailService : IEmailService + { + private readonly IWebHostEnvironment _env; + private readonly EnvironmentSettings _environmentSettings; + private readonly MailgunSettings _mailgunSettings; + + public EmailService(MailgunSettings mailgunSettings, EnvironmentSettings environmentSettings, + IWebHostEnvironment env) + { + _mailgunSettings = mailgunSettings; + _environmentSettings = environmentSettings; + _env = env; + } + + public async Task SendMagicLink(User user, string magicLink) + { + Log.Information("Sending magic link email to {email} {userid}", user.Email, user.Id); + var message = new MimeMessage(); + var builder = RetrieveTemplate("email_magic_link_login.html"); + const string endpoint = "loginbylink?token="; + + builder = BuildMagicLinkEmail(builder, magicLink, user.Email, user.Name, endpoint); + + message.To.Add(new MailboxAddress(user.Name, user.Email)); + message.Subject = "Login to Analog"; + + message.Body = builder.ToMessageBody(); + + await SendEmailAsync(message); + } + + private BodyBuilder BuildMagicLinkEmail(BodyBuilder builder, string token, string email, string name, string endpoint) + { + var baseUrl = _environmentSettings.DeploymentUrl; + + builder.HtmlBody = builder.HtmlBody.Replace("{email}", email); + builder.HtmlBody = builder.HtmlBody.Replace("{name}", name); + builder.HtmlBody = builder.HtmlBody.Replace("{expiry}", "30 minutes"); + builder.HtmlBody = builder.HtmlBody.Replace("{baseUrl}", baseUrl); + builder.HtmlBody = builder.HtmlBody.Replace("{endpoint}", endpoint); + builder.HtmlBody = builder.HtmlBody.Replace("{token}", token); + + return builder; + } + + private BodyBuilder RetrieveTemplate(string templateName) + { + var pathToTemplate = _env.WebRootPath + + Path.DirectorySeparatorChar + + "Templates" + + Path.DirectorySeparatorChar + + "EmailTemplate" + + Path.DirectorySeparatorChar + + "GeneratedEmails" + + Path.DirectorySeparatorChar + + templateName; + + var builder = new BodyBuilder(); + + using (var sourceReader = File.OpenText(pathToTemplate)) + { + builder.HtmlBody = sourceReader.ReadToEnd(); + } + + return builder; + } + + private async Task SendEmailAsync(MimeMessage mail) + { + var client = new RestClient(_mailgunSettings.MailgunApiUrl) + { + Authenticator = new HttpBasicAuthenticator("api", _mailgunSettings.ApiKey) + }; + + var request = new RestRequest(); + request.AddParameter("domain", _mailgunSettings.Domain, ParameterType.UrlSegment); + request.Resource = "{domain}/messages"; + request.AddParameter("from", "Cafe Analog "); + request.AddParameter("to", mail.To[0].ToString()); + request.AddParameter("subject", mail.Subject); + request.AddParameter("html", mail.HtmlBody); + request.Method = Method.Post; + + var response = await client.ExecutePostAsync(request); + + if (!response.IsSuccessful) + { + Log.Error("Error sending request to Mailgun. StatusCode: {statusCode} ErrorMessage: {errorMessage}", response.StatusCode, response.ErrorMessage); + } + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs index cd5d90fc..0908a8c7 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs @@ -77,5 +77,7 @@ public interface IAccountService /// Remove all existing priviliged user group assignments. Update users based on request contents /// Task UpdatePriviligedUserGroups(WebhookUpdateUserGroupRequest request); + + Task SendMagicLinkEmail(string email); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs new file mode 100644 index 00000000..95b27e75 --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using CoffeeCard.Models.DataTransferObjects.Purchase; +using CoffeeCard.Models.DataTransferObjects.User; +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Library.Services.v2 +{ + public interface IEmailService + { + Task SendMagicLink(User user, string magicLink); + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs new file mode 100644 index 00000000..4fea1ebc --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Library.Services.v2 +{ + public interface ITokenService + { + string GenerateMagicLink(string email); + Task GenerateRefreshTokenAsync(User user); + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs new file mode 100644 index 00000000..62eaa0b5 --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CoffeeCard.Common.Configuration; +using CoffeeCard.Common.Errors; +using CoffeeCard.Library.Persistence; +using CoffeeCard.Library.Utils; +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Library.Services.v2; + +public class TokenService : ITokenService +{ + private readonly CoffeeCardContext _context; + + public TokenService(CoffeeCardContext context) + { + _context = context; + } + + public string GenerateMagicLink(string email) + { + var user = _context.Users.FirstOrDefault(u => u.Email == email); + if (user == null) + { + throw new ApiException("No user found with the given email."); + } + + var guid = Guid.NewGuid().ToString(); + var magicLinkToken = new Token(guid, TokenType.MagicLink, TokenType.MagicLink.getExpiresAt()); + + user.Tokens.Add(magicLinkToken); + _context.SaveChangesAsync(); + return magicLinkToken.TokenHash; + } + + public async Task GenerateRefreshTokenAsync(User user) + { + var refreshToken = new Guid().ToString(); + _context.Tokens.Add(new Token(refreshToken, TokenType.Refresh, TokenType.Refresh.getExpiresAt())); + await _context.SaveChangesAsync(); + return refreshToken; + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/Entities/Token.cs b/coffeecard/CoffeeCard.Models/Entities/Token.cs index 575086cb..09513a22 100644 --- a/coffeecard/CoffeeCard.Models/Entities/Token.cs +++ b/coffeecard/CoffeeCard.Models/Entities/Token.cs @@ -18,6 +18,9 @@ public class Token(string tokenHash, TokenType type, DateTime expiresAt) public DateTime Expires { get; set; } = expiresAt; + public bool Revoked { get; set; } = false; + + public int? PreviousTokenId { get; set; } public override bool Equals(object? obj) { diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index bc214047..e9f36815 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -28,16 +28,18 @@ namespace CoffeeCard.WebApi.Controllers.v2 public class AccountController : ControllerBase { private readonly IAccountService _accountService; + private readonly ITokenService _tokenService; private readonly ClaimsUtilities _claimsUtilities; private readonly ILeaderboardService _leaderboardService; /// /// Initializes a new instance of the class. /// - public AccountController(IAccountService accountService, ClaimsUtilities claimsUtilities, + public AccountController(IAccountService accountService, ITokenService tokenService, ClaimsUtilities claimsUtilities, ILeaderboardService leaderboardService) { _accountService = accountService; + _tokenService = tokenService; _claimsUtilities = claimsUtilities; _leaderboardService = leaderboardService; } @@ -229,17 +231,10 @@ public async Task> SearchUsers([FromQuery][Rang [HttpPost] [AllowAnonymous] [Route("login")] - public async Task> Login([FromBody] string email) + public async Task Login([FromBody] string email) { - // Generate magic link token (MLT) - var guid = Guid.NewGuid().ToString(); - var magicLink = new Token(guid, TokenType.Refresh, TokenType.Refresh.getExpiresAt()); - - // Store MLT in DB - - // Send MLT using MailGun - - // Return 200 OK + await _accountService.SendMagicLinkEmail(email); + return Ok(); } [HttpGet] @@ -247,18 +242,11 @@ public async Task> Login([FromBody] string email) [Route("auth/token={token}")] public async Task> AuthToken(string token) { - // Validate token in DB - - // Invalidate token in DB - - // Generate refresh token - - // Generate JWT token with user claims and refresh token - - // Return JWT token + await _accountService.LoginByMagicLink(token); + // do shit } - [HttpPost] - [AuthorizeRoles(UserGroup.Customer] + // [HttpPost] + // [AuthorizeRoles(UserGroup.Customer] } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.WebApi/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index 320f9cff..37c74f0f 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -70,7 +70,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(_environment); services.AddSingleton(); services.AddScoped(); - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); @@ -85,7 +86,8 @@ public void ConfigureServices(IServiceCollection services) { services.AddTransient(); } - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/email_magic_link_login.html b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/email_magic_link_login.html new file mode 100644 index 00000000..6c8afa12 --- /dev/null +++ b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/email_magic_link_login.html @@ -0,0 +1,27 @@ +
Analog logo
Hello {name},
Use the following link to login to the app. This email expires in {expires}.
If you did not try to login you can safely disregard this message.
Login
Cafe Analog
Rued Langgaards Vej 7, 2300 Copenhagen S / CVR: 34657343
\ No newline at end of file diff --git a/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/SourceEmails/email_magic_link_login.mjml b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/SourceEmails/email_magic_link_login.mjml new file mode 100644 index 00000000..9979a803 --- /dev/null +++ b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/SourceEmails/email_magic_link_login.mjml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + Hello {name}, + + + Use the following link to login to the app. This email expires in {expires}. + + + + If you did not try to login you can safely disregard this message. + + + + Login + + + + + + + + + + + + From 0cbd67bea987e1721c0f25ddffc3d42571a027db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 24 Sep 2024 20:05:43 +0200 Subject: [PATCH 03/37] Fix some errors --- .../Persistence/CoffeecardContext.cs | 13 +++++++++++++ .../CoffeeCard.Library/Services/TokenService.cs | 5 +---- .../Services/v2/AccountService.cs | 9 ++++----- .../Services/v2/IAccountService.cs | 2 ++ .../Services/v2/PurchaseService.cs | 4 ++-- .../Controllers/v2/AccountController.cs | 7 ++++--- coffeecard/CoffeeCard.WebApi/Startup.cs | 2 +- 7 files changed, 27 insertions(+), 15 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs b/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs index 1ee05f1e..510171e8 100644 --- a/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs +++ b/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs @@ -58,6 +58,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var userGroupIntConverter = new EnumToNumberConverter(); // Use Enum to String for PurchaseTypes var purchaseTypeStringConverter = new EnumToStringConverter(); + + var tokenTypeStringConverter = new EnumToStringConverter(); modelBuilder.Entity() .Property(u => u.UserGroup) @@ -86,6 +88,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany(u => u.Tickets) .HasForeignKey(t => t.OwnerId) .OnDelete(DeleteBehavior.NoAction); + + modelBuilder.Entity() + .Property(t => t.Type) + .HasConversion(tokenTypeStringConverter); + + modelBuilder.Entity() + .HasOne(t => t.User) + .WithMany(u => u.Tokens) + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.NoAction); + } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/TokenService.cs index 8b24c2b8..87789c2d 100644 --- a/coffeecard/CoffeeCard.Library/Services/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/TokenService.cs @@ -21,13 +21,11 @@ public class TokenService : ITokenService { private readonly ClaimsUtilities _claimsUtilities; private readonly IdentitySettings _identitySettings; - private readonly CoffeeCardContext _context; - public TokenService(IdentitySettings identitySettings, ClaimsUtilities claimsUtilities, CoffeeCardContext context) + public TokenService(IdentitySettings identitySettings, ClaimsUtilities claimsUtilities) { _identitySettings = identitySettings; _claimsUtilities = claimsUtilities; - _context = context; } public string GenerateToken(IEnumerable claims) @@ -42,7 +40,6 @@ public string GenerateToken(IEnumerable claims) DateTime.UtcNow.AddHours(24), new SigningCredentials(key, SecurityAlgorithms.HmacSha256) ); - return new JwtSecurityTokenHandler().WriteToken(jwt); //the method is called WriteToken but returns a string } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index d98771cd..a1ab3a51 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -18,7 +18,7 @@ public class AccountService : IAccountService private readonly CoffeeCardContext _context; private readonly CoffeeCard.Library.Services.IEmailService _emailService; private readonly CoffeeCard.Library.Services.v2.IEmailService _emailServiceV2; - private readonly IHashService _hashService; + private readonly CoffeeCard.Library.Services.IHashService _hashService; private readonly CoffeeCard.Library.Services.ITokenService _tokenService; private readonly CoffeeCard.Library.Services.v2.ITokenService _tokenServiceV2; @@ -28,7 +28,7 @@ public AccountService( CoffeeCard.Library.Services.IEmailService emailService, CoffeeCard.Library.Services.v2.IEmailService emailServiceV2, CoffeeCard.Library.Services.v2.ITokenService tokenServiceV2, - HashService hashService + CoffeeCard.Library.Services.IHashService hashService ) { _context = context; @@ -336,8 +336,7 @@ public async Task LoginByMagicLink(string token) }; // Generate JWT token with user claims and refresh token var jwt = _tokenService.GenerateToken(claims); - - // Return JWT token + return jwt; } @@ -365,4 +364,4 @@ private async Task InvalidateTokenChain(int tokenId) } } } -} \ No newline at end of file +} diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs index 0908a8c7..453396ee 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs @@ -78,6 +78,8 @@ public interface IAccountService /// Task UpdatePriviligedUserGroups(WebhookUpdateUserGroupRequest request); + Task LoginByMagicLink(string token); + Task SendMagicLinkEmail(string email); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/PurchaseService.cs b/coffeecard/CoffeeCard.Library/Services/v2/PurchaseService.cs index 187c615e..9ab6d230 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/PurchaseService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/PurchaseService.cs @@ -20,13 +20,13 @@ namespace CoffeeCard.Library.Services.v2 public sealed class PurchaseService : IPurchaseService { private readonly CoffeeCardContext _context; - private readonly IEmailService _emailService; + private readonly CoffeeCard.Library.Services.IEmailService _emailService; private readonly IMobilePayPaymentsService _mobilePayPaymentsService; private readonly ITicketService _ticketService; private readonly IProductService _productService; public PurchaseService(CoffeeCardContext context, IMobilePayPaymentsService mobilePayPaymentsService, - ITicketService ticketService, IEmailService emailService, IProductService productService) + ITicketService ticketService, CoffeeCard.Library.Services.IEmailService emailService, IProductService productService) { _context = context; _mobilePayPaymentsService = mobilePayPaymentsService; diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index e9f36815..b56a6a9b 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -14,6 +14,7 @@ using CoffeeCard.Models.Entities; using CoffeeCard.WebApi.Helpers; using System.ComponentModel.DataAnnotations; +using CoffeeCard.Models.DataTransferObjects.User; using Microsoft.AspNetCore.Identity.Data; namespace CoffeeCard.WebApi.Controllers.v2 @@ -240,10 +241,10 @@ public async Task Login([FromBody] string email) [HttpGet] [AllowAnonymous] [Route("auth/token={token}")] - public async Task> AuthToken(string token) + public async Task> AuthToken(string magicLinkToken) { - await _accountService.LoginByMagicLink(token); - // do shit + var token = await _accountService.LoginByMagicLink(magicLinkToken); + return Ok(new TokenDto { Token = token }); } // [HttpPost] diff --git a/coffeecard/CoffeeCard.WebApi/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index 37c74f0f..54c30546 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -71,7 +71,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddScoped(); services.AddTransient(); - services.AddTransient(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); From 6cac403b08202766b20cf41fab7b6dd178d175d6 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 1 Oct 2024 22:35:17 +0200 Subject: [PATCH 04/37] Migrations and initial refresh tokens --- ...41001160733_AddTokenProperties.Designer.cs | 666 ++++++++++++++++++ .../20241001160733_AddTokenProperties.cs | 70 ++ .../CoffeeCardContextModelSnapshot.cs | 16 +- .../Persistence/CoffeecardContext.cs | 7 +- .../Services/v2/AccountService.cs | 51 +- .../Services/v2/EmailService.cs | 6 +- .../Services/v2/IAccountService.cs | 1 + .../Services/v2/ITokenService.cs | 1 + .../Services/v2/TokenService.cs | 20 +- .../CoffeeCard.Models/Entities/Token.cs | 10 +- .../Controllers/v2/AccountController.cs | 18 +- 11 files changed, 843 insertions(+), 23 deletions(-) create mode 100644 coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.Designer.cs create mode 100644 coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.cs diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.Designer.cs b/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.Designer.cs new file mode 100644 index 00000000..749c3140 --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.Designer.cs @@ -0,0 +1,666 @@ +// +using System; +using CoffeeCard.Library.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CoffeeCard.Library.Migrations +{ + [DbContext(typeof(CoffeeCardContext))] + [Migration("20241001160733_AddTokenProperties")] + partial class AddTokenProperties + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("dbo") + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CoffeeCard.Models.Entities.LoginAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Time") + .HasColumnType("datetime2"); + + b.Property("User_Id") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("User_Id"); + + b.ToTable("LoginAttempts", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItemProduct", b => + { + b.Property("MenuItemId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.HasKey("MenuItemId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("MenuItemProducts", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.PosPurhase", b => + { + b.Property("PurchaseId") + .HasColumnType("int"); + + b.Property("BaristaInitials") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("PurchaseId"); + + b.ToTable("PosPurchases", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExperienceWorth") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NumberOfTickets") + .HasColumnType("int"); + + b.Property("Price") + .HasColumnType("int"); + + b.Property("Visible") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Products", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.ProductUserGroup", b => + { + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("UserGroup") + .HasColumnType("int"); + + b.HasKey("ProductId", "UserGroup"); + + b.ToTable("ProductUserGroups", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Programme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FullName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ShortName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SortPriority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Programmes", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("ExternalTransactionId") + .HasColumnType("nvarchar(450)"); + + b.Property("NumberOfTickets") + .HasColumnType("int"); + + b.Property("OrderId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Price") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PurchasedById") + .HasColumnType("int") + .HasColumnName("PurchasedBy_Id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalTransactionId"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("PurchasedById"); + + b.ToTable("Purchases", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Statistic", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExpiryDate") + .HasColumnType("datetime2"); + + b.Property("LastSwipe") + .HasColumnType("datetime2"); + + b.Property("Preset") + .HasColumnType("int"); + + b.Property("SwipeCount") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Preset", "ExpiryDate"); + + b.ToTable("Statistics", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUsed") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("OwnerId") + .HasColumnType("int") + .HasColumnName("Owner_Id"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("PurchaseId") + .HasColumnType("int") + .HasColumnName("Purchase_Id"); + + b.Property("UsedOnMenuItemId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("UsedOnMenuItemId"); + + b.ToTable("Tickets", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Token", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Expires") + .HasColumnType("datetime2"); + + b.Property("PreviousTokenId") + .HasColumnType("int"); + + b.Property("Revoked") + .HasColumnType("bit"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Tokens", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUpdated") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Experience") + .HasColumnType("int"); + + b.Property("IsVerified") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Password") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacyActivated") + .HasColumnType("bit"); + + b.Property("ProgrammeId") + .HasColumnType("int") + .HasColumnName("Programme_Id"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserGroup") + .HasColumnType("int"); + + b.Property("UserState") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("Name"); + + b.HasIndex("ProgrammeId"); + + b.HasIndex("UserGroup"); + + b.ToTable("Users", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Voucher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUsed") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ProductId") + .HasColumnType("int") + .HasColumnName("Product_Id"); + + b.Property("PurchaseId") + .HasColumnType("int"); + + b.Property("Requester") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("UserId"); + + b.ToTable("Vouchers", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.WebhookConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("LastUpdated") + .HasColumnType("datetime2"); + + b.Property("SignatureKey") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WebhookConfigurations", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.LoginAttempt", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("LoginAttempts") + .HasForeignKey("User_Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItemProduct", b => + { + b.HasOne("CoffeeCard.Models.Entities.MenuItem", "MenuItem") + .WithMany("MenuItemProducts") + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany("MenuItemProducts") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MenuItem"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.PosPurhase", b => + { + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Purchase"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.ProductUserGroup", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany("ProductUserGroup") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.User", "PurchasedBy") + .WithMany("Purchases") + .HasForeignKey("PurchasedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("PurchasedBy"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Statistic", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("Statistics") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Ticket", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "Owner") + .WithMany("Tickets") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany("Tickets") + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.MenuItem", "UsedOnMenuItem") + .WithMany() + .HasForeignKey("UsedOnMenuItemId"); + + b.Navigation("Owner"); + + b.Navigation("Purchase"); + + b.Navigation("UsedOnMenuItem"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Token", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.HasOne("CoffeeCard.Models.Entities.Programme", "Programme") + .WithMany("Users") + .HasForeignKey("ProgrammeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Programme"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Voucher", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId"); + + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Product"); + + b.Navigation("Purchase"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItem", b => + { + b.Navigation("MenuItemProducts"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Product", b => + { + b.Navigation("MenuItemProducts"); + + b.Navigation("ProductUserGroup"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Programme", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.Navigation("LoginAttempts"); + + b.Navigation("Purchases"); + + b.Navigation("Statistics"); + + b.Navigation("Tickets"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.cs b/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.cs new file mode 100644 index 00000000..4003e7b0 --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CoffeeCard.Library.Migrations +{ + /// + public partial class AddTokenProperties : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Expires", + schema: "dbo", + table: "Tokens", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "PreviousTokenId", + schema: "dbo", + table: "Tokens", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "Revoked", + schema: "dbo", + table: "Tokens", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Type", + schema: "dbo", + table: "Tokens", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Expires", + schema: "dbo", + table: "Tokens"); + + migrationBuilder.DropColumn( + name: "PreviousTokenId", + schema: "dbo", + table: "Tokens"); + + migrationBuilder.DropColumn( + name: "Revoked", + schema: "dbo", + table: "Tokens"); + + migrationBuilder.DropColumn( + name: "Type", + schema: "dbo", + table: "Tokens"); + } + } +} diff --git a/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs b/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs index e3e7e979..0e0765ca 100644 --- a/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs +++ b/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs @@ -309,10 +309,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("Expires") + .HasColumnType("datetime2"); + + b.Property("PreviousTokenId") + .HasColumnType("int"); + + b.Property("Revoked") + .HasColumnType("bit"); + b.Property("TokenHash") .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("UserId") .HasColumnType("int") .HasColumnName("User_Id"); @@ -573,7 +586,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.HasOne("CoffeeCard.Models.Entities.User", "User") .WithMany("Tokens") - .HasForeignKey("UserId"); + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction); b.Navigation("User"); }); diff --git a/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs b/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs index 510171e8..b41c10a2 100644 --- a/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs +++ b/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs @@ -58,7 +58,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var userGroupIntConverter = new EnumToNumberConverter(); // Use Enum to String for PurchaseTypes var purchaseTypeStringConverter = new EnumToStringConverter(); - + var tokenTypeStringConverter = new EnumToStringConverter(); modelBuilder.Entity() @@ -99,6 +99,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(t => t.UserId) .OnDelete(DeleteBehavior.NoAction); + modelBuilder.Entity() + .Property(t => t.Expires).IsRequired(); + + modelBuilder.Entity() + .Property(t => t.TokenHash).IsRequired(); } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index a1ab3a51..e08f08b5 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -303,29 +303,59 @@ await _context.Users public async Task SendMagicLinkEmail(string email) { + // TODO: If no user is found, should not throw error but send a register account mail instead + // This prevents showing a malicious user if an email is registered already var user = await GetAccountByEmailAsync(email); var magicLinkToken = _tokenServiceV2.GenerateMagicLink(email); await _emailServiceV2.SendMagicLink(user, magicLinkToken); + Console.WriteLine(magicLinkToken); } public async Task LoginByMagicLink(string token) { + // Validate token in DB var foundToken = await GetTokenByMagicLink(token); if (foundToken.Revoked) { - await InvalidateTokenChain(foundToken.Id); + await InvalidateTokenChain(foundToken.Id); // Should we invalidate a the token chain if the magic link is used multiple times or should we just return already used? throw new ApiException("Token already used", 401); } - + // Invalidate token in DB foundToken.Revoked = true; await _context.SaveChangesAsync(); - // Validate token in DB + // Generate refresh token + var refreshToken = await _tokenServiceV2.GenerateRefreshTokenAsync(foundToken.User); + + var claims = new[] + { + new Claim(ClaimTypes.Email, foundToken.User!.Email), + new Claim(ClaimTypes.Name, foundToken.User.Name), + new Claim("UserId", foundToken.User.Id.ToString()), + new Claim(ClaimTypes.Role, foundToken.User.UserGroup.ToString()), + new Claim("RefreshToken", refreshToken) + }; + // Generate JWT token with user claims and refresh token + var jwt = _tokenService.GenerateToken(claims); + + return jwt; + } + + public async Task RefreshToken(string token) + { + var foundToken = await GetRefreshToken(token); + if (foundToken.Revoked) + { + await InvalidateTokenChain(foundToken.Id); // Should we invalidate a the token chain if the magic link is used multiple times or should we just return already used? + throw new ApiException("Token already used", 401); + } // Invalidate token in DB + foundToken.Revoked = true; + await _context.SaveChangesAsync(); // Generate refresh token var refreshToken = await _tokenServiceV2.GenerateRefreshTokenAsync(foundToken.User); - + var claims = new[] { new Claim(ClaimTypes.Email, foundToken.User!.Email), @@ -340,6 +370,19 @@ public async Task LoginByMagicLink(string token) return jwt; } + private async Task GetRefreshToken(string token) + { + var foundToken = await _context.Tokens + .Include(t => t.User) + .FirstOrDefaultAsync(t => t.TokenHash == token); + if (foundToken?.User == null) + { + throw new ApiException("Invalid token", 401); + } + + return foundToken; + } + private async Task GetTokenByMagicLink(string token) { var foundToken = await _context.Tokens diff --git a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs index 68fea50d..940b123b 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs @@ -33,10 +33,10 @@ public async Task SendMagicLink(User user, string magicLink) Log.Information("Sending magic link email to {email} {userid}", user.Email, user.Id); var message = new MimeMessage(); var builder = RetrieveTemplate("email_magic_link_login.html"); - const string endpoint = "loginbylink?token="; + const string endpoint = "auth?token="; builder = BuildMagicLinkEmail(builder, magicLink, user.Email, user.Name, endpoint); - + message.To.Add(new MailboxAddress(user.Name, user.Email)); message.Subject = "Login to Analog"; @@ -44,7 +44,7 @@ public async Task SendMagicLink(User user, string magicLink) await SendEmailAsync(message); } - + private BodyBuilder BuildMagicLinkEmail(BodyBuilder builder, string token, string email, string name, string endpoint) { var baseUrl = _environmentSettings.DeploymentUrl; diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs index 453396ee..f41bb324 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs @@ -81,5 +81,6 @@ public interface IAccountService Task LoginByMagicLink(string token); Task SendMagicLinkEmail(string email); + Task RefreshToken(string token); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs index 4fea1ebc..7c684d91 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs @@ -7,5 +7,6 @@ public interface ITokenService { string GenerateMagicLink(string email); Task GenerateRefreshTokenAsync(User user); + Task ValidateTokenAsync(string token); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index 62eaa0b5..44fbc96b 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -6,6 +6,7 @@ using CoffeeCard.Library.Persistence; using CoffeeCard.Library.Utils; using CoffeeCard.Models.Entities; +using Microsoft.EntityFrameworkCore; namespace CoffeeCard.Library.Services.v2; @@ -23,12 +24,12 @@ public string GenerateMagicLink(string email) var user = _context.Users.FirstOrDefault(u => u.Email == email); if (user == null) { - throw new ApiException("No user found with the given email."); + throw new ApiException("No user found with the given email."); // This check is already done in AccountService } - + var guid = Guid.NewGuid().ToString(); var magicLinkToken = new Token(guid, TokenType.MagicLink, TokenType.MagicLink.getExpiresAt()); - + user.Tokens.Add(magicLinkToken); _context.SaveChangesAsync(); return magicLinkToken.TokenHash; @@ -36,9 +37,20 @@ public string GenerateMagicLink(string email) public async Task GenerateRefreshTokenAsync(User user) { - var refreshToken = new Guid().ToString(); + var refreshToken = Guid.NewGuid().ToString(); _context.Tokens.Add(new Token(refreshToken, TokenType.Refresh, TokenType.Refresh.getExpiresAt())); await _context.SaveChangesAsync(); return refreshToken; } + + public async Task ValidateTokenAsync(string refreshToken) + { + var token = await _context.Tokens.FirstOrDefaultAsync(t => t.TokenHash == refreshToken); + if (token.Revoked) + { + // TODO: Invalidate chain of tokens + throw new ApiException("Refresh token is already used", 401); + } + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/Entities/Token.cs b/coffeecard/CoffeeCard.Models/Entities/Token.cs index 09513a22..0a2033c6 100644 --- a/coffeecard/CoffeeCard.Models/Entities/Token.cs +++ b/coffeecard/CoffeeCard.Models/Entities/Token.cs @@ -3,7 +3,7 @@ namespace CoffeeCard.Models.Entities { - public class Token(string tokenHash, TokenType type, DateTime expiresAt) + public class Token(string tokenHash, TokenType type, DateTime expires) { public int Id { get; set; } @@ -16,12 +16,12 @@ public class Token(string tokenHash, TokenType type, DateTime expiresAt) public TokenType Type { get; set; } = type; - public DateTime Expires { get; set; } = expiresAt; - + public DateTime Expires { get; set; } = expires; + public bool Revoked { get; set; } = false; - + public int? PreviousTokenId { get; set; } - + public override bool Equals(object? obj) { if (obj is Token newToken) return TokenHash.Equals(newToken.TokenHash); diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index b56a6a9b..eb87fe24 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -240,14 +240,22 @@ public async Task Login([FromBody] string email) [HttpGet] [AllowAnonymous] - [Route("auth/token={token}")] - public async Task> AuthToken(string magicLinkToken) + [Route("auth/token={magicLinkToken}")] + public async Task> AuthToken([FromRoute] string magicLinkToken) { var token = await _accountService.LoginByMagicLink(magicLinkToken); + HttpContext.Response.Cookies.Append("auth", token, new() { Expires = DateTime.Now.AddMinutes(2) }); // Expires in 2 minutes for testing purposes + return Ok(new TokenDto { Token = token }); + } + + [HttpPost] + [AuthorizeRoles(UserGroup.Customer, UserGroup.Barista, UserGroup.Manager, UserGroup.Board)] + [Route("auth/refresh")] + public async Task> Refresh() + { + var refreshToken = HttpContext.Request.Cookies.FirstOrDefault(c => c.Key == "refresh").Value; + var token = await _accountService.RefreshToken(refreshToken); return Ok(new TokenDto { Token = token }); } - - // [HttpPost] - // [AuthorizeRoles(UserGroup.Customer] } } \ No newline at end of file From 2dffbe5cd28ff3d8b7acb5928fbf5756754a063a Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 8 Oct 2024 20:24:37 +0200 Subject: [PATCH 05/37] Handle logintypes --- .../Services/AccountService.cs | 2 +- .../Services/v2/AccountService.cs | 35 ++++++++------- .../Services/v2/EmailService.cs | 4 +- .../Services/v2/IAccountService.cs | 6 +-- .../Services/v2/IEmailService.cs | 2 +- .../Services/v2/ITokenService.cs | 2 +- .../Services/v2/TokenService.cs | 12 ++--- .../v2/User/UserLoginRequest.cs | 26 +++++++++++ .../v2/User/UserLoginResponse.cs | 20 +++++++++ .../CoffeeCard.Models/Entities/LoginType.cs | 28 ++++++++++++ .../CoffeeCard.Models/Entities/Token.cs | 4 +- .../Controllers/v2/AccountController.cs | 44 ++++++++++++++----- 12 files changed, 140 insertions(+), 45 deletions(-) create mode 100644 coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs create mode 100644 coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs create mode 100644 coffeecard/CoffeeCard.Models/Entities/LoginType.cs diff --git a/coffeecard/CoffeeCard.Library/Services/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/AccountService.cs index 140c3ff9..62beee87 100644 --- a/coffeecard/CoffeeCard.Library/Services/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/AccountService.cs @@ -236,7 +236,7 @@ public async Task ForgotPasswordAsync(string email) new Claim(ClaimTypes.Role, "verification_token") }; var verificationToken = _tokenService.GenerateToken(claims); - user.Tokens.Add(new Token(verificationToken, TokenType.ResetPassword, TokenType.ResetPassword.getExpiresAt())); + user.Tokens.Add(new Token(verificationToken, TokenType.ResetPassword)); _context.SaveChanges(); await _emailService.SendVerificationEmailForLostPwAsync(user, verificationToken); } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index e08f08b5..31015704 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -301,23 +301,28 @@ await _context.Users } } - public async Task SendMagicLinkEmail(string email) + public async Task SendMagicLinkEmail(string email, LoginType loginType) { - // TODO: If no user is found, should not throw error but send a register account mail instead - // This prevents showing a malicious user if an email is registered already - var user = await GetAccountByEmailAsync(email); - var magicLinkToken = _tokenServiceV2.GenerateMagicLink(email); - await _emailServiceV2.SendMagicLink(user, magicLinkToken); - Console.WriteLine(magicLinkToken); + var user = await _context.Users + .Where(u => u.Email == email) + .FirstOrDefaultAsync(); + if (user is null) + { + // TODO: If no user is found, should not throw error but send a register account mail instead + // This prevents showing a malicious user if an email is registered already + return; + } + var magicLinkTokenHash = _tokenServiceV2.GenerateMagicLink(user); + await _emailServiceV2.SendMagicLink(user, magicLinkTokenHash, loginType); + Console.WriteLine(magicLinkTokenHash); } - public async Task LoginByMagicLink(string token) + public async Task LoginByMagicLink(string token) { // Validate token in DB var foundToken = await GetTokenByMagicLink(token); if (foundToken.Revoked) { - await InvalidateTokenChain(foundToken.Id); // Should we invalidate a the token chain if the magic link is used multiple times or should we just return already used? throw new ApiException("Token already used", 401); } // Invalidate token in DB @@ -333,20 +338,19 @@ public async Task LoginByMagicLink(string token) new Claim(ClaimTypes.Name, foundToken.User.Name), new Claim("UserId", foundToken.User.Id.ToString()), new Claim(ClaimTypes.Role, foundToken.User.UserGroup.ToString()), - new Claim("RefreshToken", refreshToken) }; - // Generate JWT token with user claims and refresh token + // Generate JWT token with user claims var jwt = _tokenService.GenerateToken(claims); - return jwt; + return new UserLoginResponse() { Jwt = jwt, RefreshToken = refreshToken }; } - public async Task RefreshToken(string token) + public async Task RefreshToken(string token) { var foundToken = await GetRefreshToken(token); if (foundToken.Revoked) { - await InvalidateTokenChain(foundToken.Id); // Should we invalidate a the token chain if the magic link is used multiple times or should we just return already used? + await InvalidateTokenChain(foundToken.Id); throw new ApiException("Token already used", 401); } // Invalidate token in DB @@ -362,12 +366,11 @@ public async Task RefreshToken(string token) new Claim(ClaimTypes.Name, foundToken.User.Name), new Claim("UserId", foundToken.User.Id.ToString()), new Claim(ClaimTypes.Role, foundToken.User.UserGroup.ToString()), - new Claim("RefreshToken", refreshToken) }; // Generate JWT token with user claims and refresh token var jwt = _tokenService.GenerateToken(claims); - return jwt; + return new UserLoginResponse() { Jwt = jwt, RefreshToken = refreshToken }; } private async Task GetRefreshToken(string token) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs index 940b123b..31e19ede 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs @@ -28,12 +28,12 @@ public EmailService(MailgunSettings mailgunSettings, EnvironmentSettings environ _env = env; } - public async Task SendMagicLink(User user, string magicLink) + public async Task SendMagicLink(User user, string magicLink, LoginType loginType) { Log.Information("Sending magic link email to {email} {userid}", user.Email, user.Id); var message = new MimeMessage(); var builder = RetrieveTemplate("email_magic_link_login.html"); - const string endpoint = "auth?token="; + var endpoint = loginType.getDeepLink(magicLink); builder = BuildMagicLinkEmail(builder, magicLink, user.Email, user.Name, endpoint); diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs index f41bb324..9502a856 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs @@ -78,9 +78,9 @@ public interface IAccountService /// Task UpdatePriviligedUserGroups(WebhookUpdateUserGroupRequest request); - Task LoginByMagicLink(string token); + Task LoginByMagicLink(string token); - Task SendMagicLinkEmail(string email); - Task RefreshToken(string token); + Task SendMagicLinkEmail(string email, LoginType loginType); + Task RefreshToken(string token); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs index 95b27e75..e075256e 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs @@ -7,6 +7,6 @@ namespace CoffeeCard.Library.Services.v2 { public interface IEmailService { - Task SendMagicLink(User user, string magicLink); + Task SendMagicLink(User user, string magicLink, LoginType loginType); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs index 7c684d91..be91cb0a 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs @@ -5,7 +5,7 @@ namespace CoffeeCard.Library.Services.v2 { public interface ITokenService { - string GenerateMagicLink(string email); + string GenerateMagicLink(User user); Task GenerateRefreshTokenAsync(User user); Task ValidateTokenAsync(string token); } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index 44fbc96b..b9e674d1 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -19,16 +19,10 @@ public TokenService(CoffeeCardContext context) _context = context; } - public string GenerateMagicLink(string email) + public string GenerateMagicLink(User user) { - var user = _context.Users.FirstOrDefault(u => u.Email == email); - if (user == null) - { - throw new ApiException("No user found with the given email."); // This check is already done in AccountService - } - var guid = Guid.NewGuid().ToString(); - var magicLinkToken = new Token(guid, TokenType.MagicLink, TokenType.MagicLink.getExpiresAt()); + var magicLinkToken = new Token(guid, TokenType.MagicLink); user.Tokens.Add(magicLinkToken); _context.SaveChangesAsync(); @@ -38,7 +32,7 @@ public string GenerateMagicLink(string email) public async Task GenerateRefreshTokenAsync(User user) { var refreshToken = Guid.NewGuid().ToString(); - _context.Tokens.Add(new Token(refreshToken, TokenType.Refresh, TokenType.Refresh.getExpiresAt())); + _context.Tokens.Add(new Token(refreshToken, TokenType.Refresh)); await _context.SaveChangesAsync(); return refreshToken; } diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs new file mode 100644 index 00000000..343e4c72 --- /dev/null +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Models.DataTransferObjects.v2.User +{ + /// + /// User login request object + /// + /// + /// { + /// "email": "john@doe.com", + /// } + /// + public class UserLoginRequest + { + /// + /// Email of user + /// + /// Email + /// john@doe.com + [EmailAddress] + public string Email { get; set; } = null!; + + public LoginType LoginType { get; set; } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs new file mode 100644 index 00000000..4b25c3ca --- /dev/null +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs @@ -0,0 +1,20 @@ +namespace CoffeeCard.Models.DataTransferObjects.v2.User +{ + /// + /// User login response object + /// + /// + public class UserLoginResponse + { + /// + /// JSON Web Token with claims for the user logging in + /// + public required string Jwt { get; set; } + + /// + /// User's Display Name + /// + /// Name + public required string RefreshToken { get; set; } + } +} diff --git a/coffeecard/CoffeeCard.Models/Entities/LoginType.cs b/coffeecard/CoffeeCard.Models/Entities/LoginType.cs new file mode 100644 index 00000000..09d01133 --- /dev/null +++ b/coffeecard/CoffeeCard.Models/Entities/LoginType.cs @@ -0,0 +1,28 @@ +namespace CoffeeCard.Models.Entities +{ + public enum LoginType + { + /// + /// Log on Shifty website + /// + Shifty, + /// + /// Log on app + /// + App + } + + public static class LoginTypeExtensions + { + public static string getDeepLink(this LoginType loginType, string tokenHash) + { + // TODO: fix to correct URLs including tokenHash + return loginType switch + { + LoginType.Shifty => $"https://shifty.prd.analogio.dk/token={tokenHash}", + LoginType.App => "https://app.analogio.dk", + _ => "https://shifty.coffee" // Register ?? + }; + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/Entities/Token.cs b/coffeecard/CoffeeCard.Models/Entities/Token.cs index 0a2033c6..08f627ed 100644 --- a/coffeecard/CoffeeCard.Models/Entities/Token.cs +++ b/coffeecard/CoffeeCard.Models/Entities/Token.cs @@ -3,7 +3,7 @@ namespace CoffeeCard.Models.Entities { - public class Token(string tokenHash, TokenType type, DateTime expires) + public class Token(string tokenHash, TokenType type) { public int Id { get; set; } @@ -16,7 +16,7 @@ public class Token(string tokenHash, TokenType type, DateTime expires) public TokenType Type { get; set; } = type; - public DateTime Expires { get; set; } = expires; + public DateTime Expires { get; set; } = type.getExpiresAt(); public bool Revoked { get; set; } = false; diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index eb87fe24..768ffad4 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -229,33 +229,57 @@ public async Task> SearchUsers([FromQuery][Rang return Ok(await _accountService.SearchUsers(filter, pageNum, pageLength)); } + /// + /// Sends a magic link to the user's email to login + /// + /// User's email + /// [HttpPost] [AllowAnonymous] [Route("login")] - public async Task Login([FromBody] string email) + public async Task Login([FromBody] UserLoginRequest request) { - await _accountService.SendMagicLinkEmail(email); + await _accountService.SendMagicLinkEmail(request.Email, request.LoginType); return Ok(); } [HttpGet] [AllowAnonymous] - [Route("auth/token={magicLinkToken}")] - public async Task> AuthToken([FromRoute] string magicLinkToken) + [ProducesResponseType(typeof(TokenDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(UserLoginResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(MessageResponseDto), StatusCodes.Status404NotFound)] + [Route("auth/token={tokenHash}&loginType={loginType}")] + public async Task> AuthToken([FromRoute] string tokenHash, [FromRoute] LoginType loginType) { - var token = await _accountService.LoginByMagicLink(magicLinkToken); - HttpContext.Response.Cookies.Append("auth", token, new() { Expires = DateTime.Now.AddMinutes(2) }); // Expires in 2 minutes for testing purposes - return Ok(new TokenDto { Token = token }); + var token = await _accountService.LoginByMagicLink(tokenHash); + return Tokenize(loginType, token); } [HttpPost] [AuthorizeRoles(UserGroup.Customer, UserGroup.Barista, UserGroup.Manager, UserGroup.Board)] [Route("auth/refresh")] - public async Task> Refresh() + public async Task> Refresh() { - var refreshToken = HttpContext.Request.Cookies.FirstOrDefault(c => c.Key == "refresh").Value; + var refreshToken = HttpContext.Request.Cookies.FirstOrDefault(c => c.Key == "refreshToken").Value; var token = await _accountService.RefreshToken(refreshToken); - return Ok(new TokenDto { Token = token }); + return Ok(token); + } + + private ActionResult Tokenize(LoginType loginType, UserLoginResponse token) + { + switch (loginType) + { + case LoginType.App: + // Redirect to app deeplink with token passed along + return Ok(token); + case LoginType.Shifty: + // Set cookie and redirect to shifty website + HttpContext.Response.Cookies.Append("refreshToken", token.RefreshToken, new() { Expires = TokenType.Refresh.getExpiresAt().ToUniversalTime() }); + return Ok(new TokenDto() { Token = token.Jwt }); + default: + return NotFound(new MessageResponseDto { Message = "Cannot determine application to login. Please re-send link from the correct application." }); + } } } } \ No newline at end of file From f42d6ea1c626b55ad2f218a9e53ae6aef7fcd91a Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 22 Oct 2024 18:56:36 +0200 Subject: [PATCH 06/37] Move things to correct services --- ...1022153731_AddTokenProperties.Designer.cs} | 5 +- ...s => 20241022153731_AddTokenProperties.cs} | 12 ----- .../CoffeeCardContextModelSnapshot.cs | 3 -- .../Services/v2/AccountService.cs | 52 ++----------------- .../Services/v2/ITokenService.cs | 1 + .../Services/v2/TokenService.cs | 29 ++++++++++- .../CoffeeCard.Models/Entities/Token.cs | 7 ++- .../Controllers/v2/AccountController.cs | 19 +++++-- 8 files changed, 53 insertions(+), 75 deletions(-) rename coffeecard/CoffeeCard.Library/Migrations/{20241001160733_AddTokenProperties.Designer.cs => 20241022153731_AddTokenProperties.Designer.cs} (99%) rename coffeecard/CoffeeCard.Library/Migrations/{20241001160733_AddTokenProperties.cs => 20241022153731_AddTokenProperties.cs} (82%) diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.Designer.cs b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.Designer.cs similarity index 99% rename from coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.Designer.cs rename to coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.Designer.cs index 749c3140..e6e5f7e1 100644 --- a/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.Designer.cs +++ b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.Designer.cs @@ -12,7 +12,7 @@ namespace CoffeeCard.Library.Migrations { [DbContext(typeof(CoffeeCardContext))] - [Migration("20241001160733_AddTokenProperties")] + [Migration("20241022153731_AddTokenProperties")] partial class AddTokenProperties { /// @@ -312,9 +312,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Expires") .HasColumnType("datetime2"); - b.Property("PreviousTokenId") - .HasColumnType("int"); - b.Property("Revoked") .HasColumnType("bit"); diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.cs b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.cs similarity index 82% rename from coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.cs rename to coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.cs index 4003e7b0..86117337 100644 --- a/coffeecard/CoffeeCard.Library/Migrations/20241001160733_AddTokenProperties.cs +++ b/coffeecard/CoffeeCard.Library/Migrations/20241022153731_AddTokenProperties.cs @@ -19,13 +19,6 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); - migrationBuilder.AddColumn( - name: "PreviousTokenId", - schema: "dbo", - table: "Tokens", - type: "int", - nullable: true); - migrationBuilder.AddColumn( name: "Revoked", schema: "dbo", @@ -51,11 +44,6 @@ protected override void Down(MigrationBuilder migrationBuilder) schema: "dbo", table: "Tokens"); - migrationBuilder.DropColumn( - name: "PreviousTokenId", - schema: "dbo", - table: "Tokens"); - migrationBuilder.DropColumn( name: "Revoked", schema: "dbo", diff --git a/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs b/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs index 0e0765ca..eb9d19d7 100644 --- a/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs +++ b/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs @@ -312,9 +312,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Expires") .HasColumnType("datetime2"); - b.Property("PreviousTokenId") - .HasColumnType("int"); - b.Property("Revoked") .HasColumnType("bit"); diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index 31015704..7b0b7e5c 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -320,11 +320,8 @@ public async Task SendMagicLinkEmail(string email, LoginType loginType) public async Task LoginByMagicLink(string token) { // Validate token in DB - var foundToken = await GetTokenByMagicLink(token); - if (foundToken.Revoked) - { - throw new ApiException("Token already used", 401); - } + var foundToken = await _tokenServiceV2.GetValidTokenByHashAsync(token); + // Invalidate token in DB foundToken.Revoked = true; await _context.SaveChangesAsync(); @@ -347,12 +344,8 @@ public async Task LoginByMagicLink(string token) public async Task RefreshToken(string token) { - var foundToken = await GetRefreshToken(token); - if (foundToken.Revoked) - { - await InvalidateTokenChain(foundToken.Id); - throw new ApiException("Token already used", 401); - } + var foundToken = await _tokenServiceV2.GetValidTokenByHashAsync(token); + // Invalidate token in DB foundToken.Revoked = true; await _context.SaveChangesAsync(); @@ -372,42 +365,5 @@ public async Task RefreshToken(string token) return new UserLoginResponse() { Jwt = jwt, RefreshToken = refreshToken }; } - - private async Task GetRefreshToken(string token) - { - var foundToken = await _context.Tokens - .Include(t => t.User) - .FirstOrDefaultAsync(t => t.TokenHash == token); - if (foundToken?.User == null) - { - throw new ApiException("Invalid token", 401); - } - - return foundToken; - } - - private async Task GetTokenByMagicLink(string token) - { - var foundToken = await _context.Tokens - .Include(t => t.User) - .FirstOrDefaultAsync(t => t.TokenHash == token); - if (foundToken?.User == null) - { - throw new ApiException("Invalid token", 401); - } - - return foundToken; - } - - private async Task InvalidateTokenChain(int tokenId) - { - // todo: invalidate all from user instead of recursion - var newerToken = _context.Tokens.FirstOrDefault(t => t.PreviousTokenId == tokenId); - if (newerToken != null) - { - newerToken.Revoked = true; - await InvalidateTokenChain(newerToken.Id); - } - } } } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs index be91cb0a..dec5f858 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs @@ -8,5 +8,6 @@ public interface ITokenService string GenerateMagicLink(User user); Task GenerateRefreshTokenAsync(User user); Task ValidateTokenAsync(string token); + Task GetValidTokenByHashAsync(string tokenHash); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index b9e674d1..23ff00a7 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -32,7 +32,7 @@ public string GenerateMagicLink(User user) public async Task GenerateRefreshTokenAsync(User user) { var refreshToken = Guid.NewGuid().ToString(); - _context.Tokens.Add(new Token(refreshToken, TokenType.Refresh)); + _context.Tokens.Add(new Token(refreshToken, TokenType.Refresh) { User = user }); await _context.SaveChangesAsync(); return refreshToken; } @@ -42,9 +42,34 @@ public async Task ValidateTokenAsync(string refreshToken) var token = await _context.Tokens.FirstOrDefaultAsync(t => t.TokenHash == refreshToken); if (token.Revoked) { - // TODO: Invalidate chain of tokens + await InvalidateRefreshTokensForUser(token.User); throw new ApiException("Refresh token is already used", 401); } throw new NotImplementedException(); } + + public async Task GetValidTokenByHashAsync(string tokenHash) + { + var foundToken = await _context.Tokens.Include(t => t.User).FirstOrDefaultAsync(t => t.TokenHash == tokenHash); + if (foundToken == null || foundToken.Revoked || foundToken.Expired()) + { + await InvalidateRefreshTokensForUser(foundToken?.User); + throw new ApiException("Invalid token", 401); + } + return foundToken; + } + + private async Task InvalidateRefreshTokensForUser(User user) + { + if (user is null) return; + + var tokens = _context.Tokens.Where(t => t.UserId == user.Id && t.Type == TokenType.Refresh); + + _context.Tokens.UpdateRange(tokens); + foreach (var token in tokens) + { + token.Revoked = true; + } + await _context.SaveChangesAsync(); + } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/Entities/Token.cs b/coffeecard/CoffeeCard.Models/Entities/Token.cs index 08f627ed..15878c44 100644 --- a/coffeecard/CoffeeCard.Models/Entities/Token.cs +++ b/coffeecard/CoffeeCard.Models/Entities/Token.cs @@ -20,8 +20,6 @@ public class Token(string tokenHash, TokenType type) public bool Revoked { get; set; } = false; - public int? PreviousTokenId { get; set; } - public override bool Equals(object? obj) { if (obj is Token newToken) return TokenHash.Equals(newToken.TokenHash); @@ -32,5 +30,10 @@ public override int GetHashCode() { return HashCode.Combine(Id, TokenHash, User); } + + public bool Expired() + { + return DateTime.UtcNow > Expires; + } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index 768ffad4..ba5b6fdc 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -258,12 +258,23 @@ public async Task> AuthToken([FromRoute] string [HttpPost] [AuthorizeRoles(UserGroup.Customer, UserGroup.Barista, UserGroup.Manager, UserGroup.Board)] - [Route("auth/refresh")] - public async Task> Refresh() + [Route("auth/refresh/loginType={loginType}")] + public async Task> Refresh([FromRoute] LoginType loginType, string refreshToken = null) { - var refreshToken = HttpContext.Request.Cookies.FirstOrDefault(c => c.Key == "refreshToken").Value; + switch (loginType) + { + case LoginType.App: + if (refreshToken is null) return NotFound(new MessageResponseDto { Message = "Refresh token required for app refresh." }); + break; + case LoginType.Shifty: + refreshToken = HttpContext.Request.Cookies.FirstOrDefault(c => c.Key == "refreshToken").Value; + break; + default: + return NotFound(new MessageResponseDto { Message = "Cannot determine application to login." }); + } + var token = await _accountService.RefreshToken(refreshToken); - return Ok(token); + return Tokenize(loginType, token); } private ActionResult Tokenize(LoginType loginType, UserLoginResponse token) From a793751c65da900ffee55fef4d1c709a9401daf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 22 Oct 2024 21:18:14 +0200 Subject: [PATCH 07/37] Change controller to use query parameters --- .../Controllers/v2/AccountController.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index ba5b6fdc..ce441725 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -237,10 +237,12 @@ public async Task> SearchUsers([FromQuery][Rang [HttpPost] [AllowAnonymous] [Route("login")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesDefaultResponseType] public async Task Login([FromBody] UserLoginRequest request) { await _accountService.SendMagicLinkEmail(request.Email, request.LoginType); - return Ok(); + return new NoContentResult(); } [HttpGet] @@ -249,8 +251,8 @@ public async Task Login([FromBody] UserLoginRequest request) [ProducesResponseType(typeof(UserLoginResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(MessageResponseDto), StatusCodes.Status404NotFound)] - [Route("auth/token={tokenHash}&loginType={loginType}")] - public async Task> AuthToken([FromRoute] string tokenHash, [FromRoute] LoginType loginType) + [Route("auth/login")] + public async Task> AuthToken([FromQuery] string tokenHash, [FromQuery] LoginType loginType) { var token = await _accountService.LoginByMagicLink(tokenHash); return Tokenize(loginType, token); @@ -258,7 +260,7 @@ public async Task> AuthToken([FromRoute] string [HttpPost] [AuthorizeRoles(UserGroup.Customer, UserGroup.Barista, UserGroup.Manager, UserGroup.Board)] - [Route("auth/refresh/loginType={loginType}")] + [Route("auth/refresh")] public async Task> Refresh([FromRoute] LoginType loginType, string refreshToken = null) { switch (loginType) From 4f38d77cc1510fa135591d731059c2bfc8247283 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 5 Nov 2024 18:46:58 +0100 Subject: [PATCH 08/37] Changes required for shifty --- coffeecard/CoffeeCard.Library/Services/TokenService.cs | 2 +- .../CoffeeCard.WebApi/Controllers/v2/AccountController.cs | 8 ++++---- coffeecard/CoffeeCard.WebApi/Startup.cs | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/TokenService.cs index 87789c2d..aba07d04 100644 --- a/coffeecard/CoffeeCard.Library/Services/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/TokenService.cs @@ -37,7 +37,7 @@ public string GenerateToken(IEnumerable claims) "Everyone", claims, DateTime.UtcNow, - DateTime.UtcNow.AddHours(24), + DateTime.UtcNow.AddMinutes(1), new SigningCredentials(key, SecurityAlgorithms.HmacSha256) ); return new JwtSecurityTokenHandler().WriteToken(jwt); //the method is called WriteToken but returns a string diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index ce441725..e1f036fd 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -259,9 +259,9 @@ public async Task> AuthToken([FromQuery] string } [HttpPost] - [AuthorizeRoles(UserGroup.Customer, UserGroup.Barista, UserGroup.Manager, UserGroup.Board)] + [AllowAnonymous] [Route("auth/refresh")] - public async Task> Refresh([FromRoute] LoginType loginType, string refreshToken = null) + public async Task> Refresh([FromQuery] LoginType loginType, string refreshToken = null) { switch (loginType) { @@ -288,8 +288,8 @@ private ActionResult Tokenize(LoginType loginType, UserLoginR return Ok(token); case LoginType.Shifty: // Set cookie and redirect to shifty website - HttpContext.Response.Cookies.Append("refreshToken", token.RefreshToken, new() { Expires = TokenType.Refresh.getExpiresAt().ToUniversalTime() }); - return Ok(new TokenDto() { Token = token.Jwt }); + HttpContext.Response.Cookies.Append("refreshToken", token.RefreshToken, new() { Domain = "analogio.dk", Expires = TokenType.Refresh.getExpiresAt().ToUniversalTime(), SameSite = SameSiteMode.None, Secure = true }); + return Ok(new UserLoginResponse() { Jwt = token.Jwt, RefreshToken = "" }); default: return NotFound(new MessageResponseDto { Message = "Cannot determine application to login. Please re-send link from the correct application." }); } diff --git a/coffeecard/CoffeeCard.WebApi/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index 54c30546..b406dd26 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -126,8 +126,8 @@ public void ConfigureServices(IServiceCollection services) options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); - services.AddCors(options => options.AddDefaultPolicy(builder => - builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); + services.AddCors(options => options.AddPolicy("local", builder => + builder.WithOrigins("https://shifty.local.analogio.dk:8001").AllowAnyMethod().AllowAnyHeader().AllowCredentials())); services.AddApiVersioning(config => { @@ -285,7 +285,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApiVers app.UseRouting(); - app.UseCors(); + app.UseCors("local"); app.UseAuthentication(); app.UseAuthorization(); From e85e55bb426634d81364293d3e7ddc8cb743a208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 5 Nov 2024 19:08:03 +0100 Subject: [PATCH 09/37] Revert startup.cs --- coffeecard/CoffeeCard.WebApi/Startup.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coffeecard/CoffeeCard.WebApi/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index b406dd26..54c30546 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -126,8 +126,8 @@ public void ConfigureServices(IServiceCollection services) options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); - services.AddCors(options => options.AddPolicy("local", builder => - builder.WithOrigins("https://shifty.local.analogio.dk:8001").AllowAnyMethod().AllowAnyHeader().AllowCredentials())); + services.AddCors(options => options.AddDefaultPolicy(builder => + builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); services.AddApiVersioning(config => { @@ -285,7 +285,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApiVers app.UseRouting(); - app.UseCors("local"); + app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); From e56f4f7763946f963392fa6ab7d433c5ae30c549 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 5 Nov 2024 19:30:24 +0100 Subject: [PATCH 10/37] Always send refreshtoken --- .vscode/settings.json | 4 ++ .../Services/TokenService.cs | 2 +- .../Controllers/v2/AccountController.cs | 37 ++++--------------- coffeecard/CoffeeCard.WebApi/appsettings.json | 2 +- 4 files changed, 13 insertions(+), 32 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index c90c169a..78c51da9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,9 @@ "yaml.schemas": { "https://json.schemastore.org/github-workflow.json": "file:///c%3A/code/private/analogio/analog-core/.github/actions/deploy.yml", "https://json.schemastore.org/github-action.json": "file:///c%3A/code/private/analogio/analog-core/.github/actions/core-sonarcloud.yml" + }, + "sonarlint.connectedMode.project": { + "connectionId": "Analog", + "projectKey": "AnalogIO_analog-core" } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/TokenService.cs index aba07d04..87789c2d 100644 --- a/coffeecard/CoffeeCard.Library/Services/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/TokenService.cs @@ -37,7 +37,7 @@ public string GenerateToken(IEnumerable claims) "Everyone", claims, DateTime.UtcNow, - DateTime.UtcNow.AddMinutes(1), + DateTime.UtcNow.AddHours(24), new SigningCredentials(key, SecurityAlgorithms.HmacSha256) ); return new JwtSecurityTokenHandler().WriteToken(jwt); //the method is called WriteToken but returns a string diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index e1f036fd..f7d96610 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -255,44 +255,21 @@ public async Task Login([FromBody] UserLoginRequest request) public async Task> AuthToken([FromQuery] string tokenHash, [FromQuery] LoginType loginType) { var token = await _accountService.LoginByMagicLink(tokenHash); - return Tokenize(loginType, token); + return Ok(token); } [HttpPost] [AllowAnonymous] [Route("auth/refresh")] - public async Task> Refresh([FromQuery] LoginType loginType, string refreshToken = null) + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(MessageResponseDto), StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] + public async Task> Refresh(string refreshToken) { - switch (loginType) - { - case LoginType.App: - if (refreshToken is null) return NotFound(new MessageResponseDto { Message = "Refresh token required for app refresh." }); - break; - case LoginType.Shifty: - refreshToken = HttpContext.Request.Cookies.FirstOrDefault(c => c.Key == "refreshToken").Value; - break; - default: - return NotFound(new MessageResponseDto { Message = "Cannot determine application to login." }); - } + if (refreshToken is null) return NotFound(new MessageResponseDto { Message = "Refresh token required for app refresh." }); var token = await _accountService.RefreshToken(refreshToken); - return Tokenize(loginType, token); - } - - private ActionResult Tokenize(LoginType loginType, UserLoginResponse token) - { - switch (loginType) - { - case LoginType.App: - // Redirect to app deeplink with token passed along - return Ok(token); - case LoginType.Shifty: - // Set cookie and redirect to shifty website - HttpContext.Response.Cookies.Append("refreshToken", token.RefreshToken, new() { Domain = "analogio.dk", Expires = TokenType.Refresh.getExpiresAt().ToUniversalTime(), SameSite = SameSiteMode.None, Secure = true }); - return Ok(new UserLoginResponse() { Jwt = token.Jwt, RefreshToken = "" }); - default: - return NotFound(new MessageResponseDto { Message = "Cannot determine application to login. Please re-send link from the correct application." }); - } + return Ok(token); } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.WebApi/appsettings.json b/coffeecard/CoffeeCard.WebApi/appsettings.json index d6b97d62..6589e039 100644 --- a/coffeecard/CoffeeCard.WebApi/appsettings.json +++ b/coffeecard/CoffeeCard.WebApi/appsettings.json @@ -9,7 +9,7 @@ "DeploymentUrl": "https://localhost:8080/" }, "DatabaseSettings": { - "ConnectionString": "Server=mssql;Initial Catalog=master;User=sa;Password=Your_password123;TrustServerCertificate=True;", + "ConnectionString": "Server=localhost;Initial Catalog=master;User=sa;Password=Your_password123;TrustServerCertificate=True;", "SchemaName": "dbo" }, "IdentitySettings": { From 55ce8a9eb3f52cbd27914391afbe27e2e298a3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 5 Nov 2024 20:01:31 +0100 Subject: [PATCH 11/37] Update magic link mail --- .../Configuration/EnvironmentSettings.cs | 2 ++ .../Services/v2/EmailService.cs | 21 ++++++++++++------- .../CoffeeCard.Models/Entities/LoginType.cs | 4 ++-- .../Controllers/v2/AccountController.cs | 1 - coffeecard/CoffeeCard.WebApi/appsettings.json | 5 +++-- .../email_magic_link_login.html | 2 +- .../SourceEmails/email_magic_link_login.mjml | 2 +- 7 files changed, 22 insertions(+), 15 deletions(-) diff --git a/coffeecard/CoffeeCard.Common/Configuration/EnvironmentSettings.cs b/coffeecard/CoffeeCard.Common/Configuration/EnvironmentSettings.cs index 5741fdd2..dbb7115f 100644 --- a/coffeecard/CoffeeCard.Common/Configuration/EnvironmentSettings.cs +++ b/coffeecard/CoffeeCard.Common/Configuration/EnvironmentSettings.cs @@ -11,6 +11,8 @@ public class EnvironmentSettings : IValidatable [Required] public string DeploymentUrl { get; set; } + [Required] public string ShiftyUrl { get; set; } + public void Validate() { Validator.ValidateObject(this, new ValidationContext(this), true); diff --git a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs index 31e19ede..3392e9bb 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs @@ -33,9 +33,18 @@ public async Task SendMagicLink(User user, string magicLink, LoginType loginType Log.Information("Sending magic link email to {email} {userid}", user.Email, user.Id); var message = new MimeMessage(); var builder = RetrieveTemplate("email_magic_link_login.html"); - var endpoint = loginType.getDeepLink(magicLink); + var baseUrl = loginType switch + { + LoginType.Shifty => _environmentSettings.ShiftyUrl, + LoginType.App => throw new NotImplementedException(), + _ => throw new NotImplementedException(), + }; + + var deeplink = loginType.GetDeepLink(baseUrl, magicLink); - builder = BuildMagicLinkEmail(builder, magicLink, user.Email, user.Name, endpoint); + Console.WriteLine($"MAGIC LINK HREF: {deeplink}"); + + builder = BuildMagicLinkEmail(builder, user.Email, user.Name, deeplink); message.To.Add(new MailboxAddress(user.Name, user.Email)); message.Subject = "Login to Analog"; @@ -45,16 +54,12 @@ public async Task SendMagicLink(User user, string magicLink, LoginType loginType await SendEmailAsync(message); } - private BodyBuilder BuildMagicLinkEmail(BodyBuilder builder, string token, string email, string name, string endpoint) + private BodyBuilder BuildMagicLinkEmail(BodyBuilder builder, string email, string name, string deeplink) { - var baseUrl = _environmentSettings.DeploymentUrl; - builder.HtmlBody = builder.HtmlBody.Replace("{email}", email); builder.HtmlBody = builder.HtmlBody.Replace("{name}", name); builder.HtmlBody = builder.HtmlBody.Replace("{expiry}", "30 minutes"); - builder.HtmlBody = builder.HtmlBody.Replace("{baseUrl}", baseUrl); - builder.HtmlBody = builder.HtmlBody.Replace("{endpoint}", endpoint); - builder.HtmlBody = builder.HtmlBody.Replace("{token}", token); + builder.HtmlBody = builder.HtmlBody.Replace("{deeplink}", deeplink); return builder; } diff --git a/coffeecard/CoffeeCard.Models/Entities/LoginType.cs b/coffeecard/CoffeeCard.Models/Entities/LoginType.cs index 09d01133..5bc3bc72 100644 --- a/coffeecard/CoffeeCard.Models/Entities/LoginType.cs +++ b/coffeecard/CoffeeCard.Models/Entities/LoginType.cs @@ -14,12 +14,12 @@ public enum LoginType public static class LoginTypeExtensions { - public static string getDeepLink(this LoginType loginType, string tokenHash) + public static string GetDeepLink(this LoginType loginType, string baseUrl, string tokenHash) { // TODO: fix to correct URLs including tokenHash return loginType switch { - LoginType.Shifty => $"https://shifty.prd.analogio.dk/token={tokenHash}", + LoginType.Shifty => $"{baseUrl}auth?token={tokenHash}", LoginType.App => "https://app.analogio.dk", _ => "https://shifty.coffee" // Register ?? }; diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index f7d96610..c70e0178 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -272,4 +272,3 @@ public async Task> Refresh(string refreshToken) return Ok(token); } } -} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.WebApi/appsettings.json b/coffeecard/CoffeeCard.WebApi/appsettings.json index 6589e039..7954b2bd 100644 --- a/coffeecard/CoffeeCard.WebApi/appsettings.json +++ b/coffeecard/CoffeeCard.WebApi/appsettings.json @@ -6,10 +6,11 @@ "EnvironmentSettings": { "EnvironmentType": "LocalDevelopment", "MinAppVersion": "2.0.0", - "DeploymentUrl": "https://localhost:8080/" + "DeploymentUrl": "https://localhost:8081/", + "ShiftyUrl": "https://localhost:8001/" }, "DatabaseSettings": { - "ConnectionString": "Server=localhost;Initial Catalog=master;User=sa;Password=Your_password123;TrustServerCertificate=True;", + "ConnectionString": "Server=mssql;Initial Catalog=master;User=sa;Password=Your_password123;TrustServerCertificate=True;", "SchemaName": "dbo" }, "IdentitySettings": { diff --git a/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/email_magic_link_login.html b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/email_magic_link_login.html index 6c8afa12..3d419bbc 100644 --- a/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/email_magic_link_login.html +++ b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/email_magic_link_login.html @@ -24,4 +24,4 @@ .moz-text-html .mj-column-per-50 { width:50% !important; max-width: 50%; }
Analog logo
Hello {name},
Use the following link to login to the app. This email expires in {expires}.
If you did not try to login you can safely disregard this message.
Login
Cafe Analog
Rued Langgaards Vej 7, 2300 Copenhagen S / CVR: 34657343
\ No newline at end of file + }
Analog logo
Hello {name},
Use the following link to login to the app. This email expires in {expires}.
If you did not try to login you can safely disregard this message.
Login
Cafe Analog
Rued Langgaards Vej 7, 2300 Copenhagen S / CVR: 34657343
\ No newline at end of file diff --git a/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/SourceEmails/email_magic_link_login.mjml b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/SourceEmails/email_magic_link_login.mjml index 9979a803..446426d4 100644 --- a/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/SourceEmails/email_magic_link_login.mjml +++ b/coffeecard/CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/SourceEmails/email_magic_link_login.mjml @@ -25,7 +25,7 @@ If you did not try to login you can safely disregard this message. - + Login From 24c978ce491e7b7a9c751853791a238d616f0e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 5 Nov 2024 20:13:05 +0100 Subject: [PATCH 12/37] Fix missing curly? --- .../Controllers/v2/AccountController.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index c70e0178..fd0c270f 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -36,7 +36,8 @@ public class AccountController : ControllerBase /// /// Initializes a new instance of the class. /// - public AccountController(IAccountService accountService, ITokenService tokenService, ClaimsUtilities claimsUtilities, + public AccountController(IAccountService accountService, ITokenService tokenService, + ClaimsUtilities claimsUtilities, ILeaderboardService leaderboardService) { _accountService = accountService; @@ -159,7 +160,8 @@ public async Task> EmailExists([FromBody] Emai [ProducesResponseType(typeof(ApiError), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [Route("{id:int}/user-group")] - public async Task UpdateAccountUserGroup([FromRoute] int id, [FromBody] UpdateUserGroupRequest updateUserGroupRequest) + public async Task UpdateAccountUserGroup([FromRoute] int id, + [FromBody] UpdateUserGroupRequest updateUserGroupRequest) { await _accountService.UpdateUserGroup(updateUserGroupRequest.UserGroup, id); @@ -224,7 +226,9 @@ private async Task UserWithRanking(User user) [ProducesResponseType(typeof(ApiError), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(UserSearchResponse), StatusCodes.Status200OK)] [Route("search")] - public async Task> SearchUsers([FromQuery][Range(0, int.MaxValue)] int pageNum, [FromQuery] string filter = "", [FromQuery][Range(1, 100)] int pageLength = 30) + public async Task> SearchUsers( + [FromQuery][Range(0, int.MaxValue)] int pageNum, [FromQuery] string filter = "", + [FromQuery][Range(1, 100)] int pageLength = 30) { return Ok(await _accountService.SearchUsers(filter, pageNum, pageLength)); } @@ -252,7 +256,8 @@ public async Task Login([FromBody] UserLoginRequest request) [ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(MessageResponseDto), StatusCodes.Status404NotFound)] [Route("auth/login")] - public async Task> AuthToken([FromQuery] string tokenHash, [FromQuery] LoginType loginType) + public async Task> AuthToken([FromQuery] string tokenHash, + [FromQuery] LoginType loginType) { var token = await _accountService.LoginByMagicLink(tokenHash); return Ok(token); @@ -266,9 +271,11 @@ public async Task> AuthToken([FromQuery] string [ProducesDefaultResponseType] public async Task> Refresh(string refreshToken) { - if (refreshToken is null) return NotFound(new MessageResponseDto { Message = "Refresh token required for app refresh." }); + if (refreshToken is null) + return NotFound(new MessageResponseDto { Message = "Refresh token required for app refresh." }); var token = await _accountService.RefreshToken(refreshToken); return Ok(token); } } +} From 58a658cb0c5e9e8baf23f07f05cf81cb64398bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 5 Nov 2024 21:42:33 +0100 Subject: [PATCH 13/37] Simplify auth flow --- .../Services/v2/AccountService.cs | 26 +------------------ .../Services/v2/IAccountService.cs | 3 +-- .../Services/v2/ITokenService.cs | 1 - .../Services/v2/TokenService.cs | 11 -------- .../Controllers/v2/AccountController.cs | 24 ++++------------- 5 files changed, 7 insertions(+), 58 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index 7b0b7e5c..f4a88c8c 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -317,7 +317,7 @@ public async Task SendMagicLinkEmail(string email, LoginType loginType) Console.WriteLine(magicLinkTokenHash); } - public async Task LoginByMagicLink(string token) + public async Task GenerateTokenPair(string token) { // Validate token in DB var foundToken = await _tokenServiceV2.GetValidTokenByHashAsync(token); @@ -341,29 +341,5 @@ public async Task LoginByMagicLink(string token) return new UserLoginResponse() { Jwt = jwt, RefreshToken = refreshToken }; } - - public async Task RefreshToken(string token) - { - var foundToken = await _tokenServiceV2.GetValidTokenByHashAsync(token); - - // Invalidate token in DB - foundToken.Revoked = true; - await _context.SaveChangesAsync(); - - // Generate refresh token - var refreshToken = await _tokenServiceV2.GenerateRefreshTokenAsync(foundToken.User); - - var claims = new[] - { - new Claim(ClaimTypes.Email, foundToken.User!.Email), - new Claim(ClaimTypes.Name, foundToken.User.Name), - new Claim("UserId", foundToken.User.Id.ToString()), - new Claim(ClaimTypes.Role, foundToken.User.UserGroup.ToString()), - }; - // Generate JWT token with user claims and refresh token - var jwt = _tokenService.GenerateToken(claims); - - return new UserLoginResponse() { Jwt = jwt, RefreshToken = refreshToken }; - } } } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs index 9502a856..40d18d2a 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs @@ -78,9 +78,8 @@ public interface IAccountService /// Task UpdatePriviligedUserGroups(WebhookUpdateUserGroupRequest request); - Task LoginByMagicLink(string token); + Task GenerateTokenPair(string token); Task SendMagicLinkEmail(string email, LoginType loginType); - Task RefreshToken(string token); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs index dec5f858..0b136649 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs @@ -7,7 +7,6 @@ public interface ITokenService { string GenerateMagicLink(User user); Task GenerateRefreshTokenAsync(User user); - Task ValidateTokenAsync(string token); Task GetValidTokenByHashAsync(string tokenHash); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index 23ff00a7..efca6d4f 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -37,17 +37,6 @@ public async Task GenerateRefreshTokenAsync(User user) return refreshToken; } - public async Task ValidateTokenAsync(string refreshToken) - { - var token = await _context.Tokens.FirstOrDefaultAsync(t => t.TokenHash == refreshToken); - if (token.Revoked) - { - await InvalidateRefreshTokensForUser(token.User); - throw new ApiException("Refresh token is already used", 401); - } - throw new NotImplementedException(); - } - public async Task GetValidTokenByHashAsync(string tokenHash) { var foundToken = await _context.Tokens.Include(t => t.User).FirstOrDefaultAsync(t => t.TokenHash == tokenHash); diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index fd0c270f..2c2b29b2 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -249,32 +249,18 @@ public async Task Login([FromBody] UserLoginRequest request) return new NoContentResult(); } - [HttpGet] - [AllowAnonymous] - [ProducesResponseType(typeof(TokenDto), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(UserLoginResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)] - [ProducesResponseType(typeof(MessageResponseDto), StatusCodes.Status404NotFound)] - [Route("auth/login")] - public async Task> AuthToken([FromQuery] string tokenHash, - [FromQuery] LoginType loginType) - { - var token = await _accountService.LoginByMagicLink(tokenHash); - return Ok(token); - } - [HttpPost] [AllowAnonymous] - [Route("auth/refresh")] + [Route("auth")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(MessageResponseDto), StatusCodes.Status404NotFound)] [ProducesDefaultResponseType] - public async Task> Refresh(string refreshToken) + public async Task> Authenticate(string tokenHash) { - if (refreshToken is null) - return NotFound(new MessageResponseDto { Message = "Refresh token required for app refresh." }); + if (tokenHash is null) + return NotFound(new MessageResponseDto { Message = "Token required for app authentication." }); - var token = await _accountService.RefreshToken(refreshToken); + var token = await _accountService.GenerateTokenPair(tokenHash); return Ok(token); } } From 4acafe84b9b06fdfc65ee240f699f92035c92abb Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 12 Nov 2024 17:00:01 +0100 Subject: [PATCH 14/37] Resolve SonarCloud issues --- .../Services/v2/AccountService.cs | 4 +- .../Services/v2/EmailService.cs | 2 +- .../v2/User/UserLoginRequest.cs | 5 +++ .../v2/User/UserLoginResponse.cs | 8 +++- .../CoffeeCard.Models/Entities/LoginType.cs | 24 ++++++++++-- .../CoffeeCard.Models/Entities/Token.cs | 39 +++++++++++++++++++ .../CoffeeCard.Models/Entities/TokenType.cs | 24 ++++++++++++ .../Controllers/v2/AccountController.cs | 9 +++-- 8 files changed, 103 insertions(+), 12 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index f4a88c8c..c0433296 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -308,13 +308,11 @@ public async Task SendMagicLinkEmail(string email, LoginType loginType) .FirstOrDefaultAsync(); if (user is null) { - // TODO: If no user is found, should not throw error but send a register account mail instead - // This prevents showing a malicious user if an email is registered already + // Should not throw error to prevent showing a malicious user if an email is already registered return; } var magicLinkTokenHash = _tokenServiceV2.GenerateMagicLink(user); await _emailServiceV2.SendMagicLink(user, magicLinkTokenHash, loginType); - Console.WriteLine(magicLinkTokenHash); } public async Task GenerateTokenPair(string token) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs index 3392e9bb..c1dba24b 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs @@ -54,7 +54,7 @@ public async Task SendMagicLink(User user, string magicLink, LoginType loginType await SendEmailAsync(message); } - private BodyBuilder BuildMagicLinkEmail(BodyBuilder builder, string email, string name, string deeplink) + private static BodyBuilder BuildMagicLinkEmail(BodyBuilder builder, string email, string name, string deeplink) { builder.HtmlBody = builder.HtmlBody.Replace("{email}", email); builder.HtmlBody = builder.HtmlBody.Replace("{name}", name); diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs index 343e4c72..c53e017d 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs @@ -21,6 +21,11 @@ public class UserLoginRequest [EmailAddress] public string Email { get; set; } = null!; + /// + /// Defines which application should open on login + /// + /// LoginType + /// Shifty public LoginType LoginType { get; set; } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs index 4b25c3ca..c6357226 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs @@ -4,6 +4,11 @@ namespace CoffeeCard.Models.DataTransferObjects.v2.User /// User login response object /// /// + /// { + /// "jwt": "hidden", + /// "refreshToken": "hidden" + /// } + /// public class UserLoginResponse { /// @@ -12,9 +17,8 @@ public class UserLoginResponse public required string Jwt { get; set; } /// - /// User's Display Name + /// Token used to obtain a new JWT token on expiration /// - /// Name public required string RefreshToken { get; set; } } } diff --git a/coffeecard/CoffeeCard.Models/Entities/LoginType.cs b/coffeecard/CoffeeCard.Models/Entities/LoginType.cs index 5bc3bc72..21591de4 100644 --- a/coffeecard/CoffeeCard.Models/Entities/LoginType.cs +++ b/coffeecard/CoffeeCard.Models/Entities/LoginType.cs @@ -1,5 +1,13 @@ +using CoffeeCard.Common.Errors; + namespace CoffeeCard.Models.Entities { + /// + /// Enum for applications to log in to + /// + /// + /// Shifty + /// public enum LoginType { /// @@ -12,16 +20,26 @@ public enum LoginType App } + + /// + /// Extension methods for LoginType + /// public static class LoginTypeExtensions { + /// + /// Get the deep link for the correct application + /// + /// The application to log in to + /// The base URL for the application + /// The generated token associated with a user + /// string + /// Unable to resolve application to log in to public static string GetDeepLink(this LoginType loginType, string baseUrl, string tokenHash) { - // TODO: fix to correct URLs including tokenHash return loginType switch { LoginType.Shifty => $"{baseUrl}auth?token={tokenHash}", - LoginType.App => "https://app.analogio.dk", - _ => "https://shifty.coffee" // Register ?? + _ => throw new ApiException("Deep link for the given application has not been implemented"), }; } } diff --git a/coffeecard/CoffeeCard.Models/Entities/Token.cs b/coffeecard/CoffeeCard.Models/Entities/Token.cs index 15878c44..464c6787 100644 --- a/coffeecard/CoffeeCard.Models/Entities/Token.cs +++ b/coffeecard/CoffeeCard.Models/Entities/Token.cs @@ -3,34 +3,73 @@ namespace CoffeeCard.Models.Entities { + /// + /// Shared Token class for different token types + /// + /// Hash used to identify the token + /// public class Token(string tokenHash, TokenType type) { + /// + /// The ID of the token + /// public int Id { get; set; } + /// + /// The randomly generated hash used to find the token + /// public string TokenHash { get; set; } = tokenHash; + /// + /// The ID of the user that the token is associated with + /// [Column(name: "User_Id")] public int? UserId { get; set; } + /// + /// The user that the token is associated with + /// public User? User { get; set; } + /// + /// The type of token + /// + /// + /// RefreshToken + /// public TokenType Type { get; set; } = type; + /// + /// The date and time when the Token is no longer valid + /// public DateTime Expires { get; set; } = type.getExpiresAt(); + /// + /// Whether or not the token has been revoked + /// public bool Revoked { get; set; } = false; + /// + /// Determines if two tokens are equal + /// public override bool Equals(object? obj) { if (obj is Token newToken) return TokenHash.Equals(newToken.TokenHash); return false; } + /// + /// Gets the hash code of the token + /// public override int GetHashCode() { return HashCode.Combine(Id, TokenHash, User); } + /// + /// Determines if the token has expired + /// + /// bool public bool Expired() { return DateTime.UtcNow > Expires; diff --git a/coffeecard/CoffeeCard.Models/Entities/TokenType.cs b/coffeecard/CoffeeCard.Models/Entities/TokenType.cs index f4ae79f5..5548a493 100644 --- a/coffeecard/CoffeeCard.Models/Entities/TokenType.cs +++ b/coffeecard/CoffeeCard.Models/Entities/TokenType.cs @@ -2,16 +2,40 @@ namespace CoffeeCard.Models.Entities { + /// + /// Enum for the different types of token + /// public enum TokenType { + /// + /// Token used to reset a user's password + /// ResetPassword, + + /// + /// Token used to delete a user's account + /// DeleteAccount, + + /// + /// Token used to log in to an application + /// MagicLink, + + /// + /// Token used to refresh a JWT token + /// Refresh } + /// + /// Extension methods for TokenType + /// public static class TokenTypeExtensions { + /// + /// Get the default date and time when a token expires + /// public static DateTime getExpiresAt(this TokenType tokenType) { return tokenType switch diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index 2c2b29b2..80fc6fd0 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -29,19 +29,17 @@ namespace CoffeeCard.WebApi.Controllers.v2 public class AccountController : ControllerBase { private readonly IAccountService _accountService; - private readonly ITokenService _tokenService; private readonly ClaimsUtilities _claimsUtilities; private readonly ILeaderboardService _leaderboardService; /// /// Initializes a new instance of the class. /// - public AccountController(IAccountService accountService, ITokenService tokenService, + public AccountController(IAccountService accountService, ClaimsUtilities claimsUtilities, ILeaderboardService leaderboardService) { _accountService = accountService; - _tokenService = tokenService; _claimsUtilities = claimsUtilities; _leaderboardService = leaderboardService; } @@ -249,6 +247,11 @@ public async Task Login([FromBody] UserLoginRequest request) return new NoContentResult(); } + /// + /// Authenticates the user with the token hash from a magic link + /// + /// The token hash from the magic link + /// A JSON Web Token used to authenticate for other endpoints and a refresh token to re-authenticate without a new magic link [HttpPost] [AllowAnonymous] [Route("auth")] From 2a2fda14eca3d8a85f8c8e1944cdad80b66df9e0 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 12 Nov 2024 17:21:41 +0100 Subject: [PATCH 15/37] Fix tests for new constructors --- .../Services/AccountServiceTest.cs | 4 +- .../Services/TokenServiceTest.cs | 4 +- .../Services/v2/AccountServiceTest.cs | 127 ++++++++++++++---- 3 files changed, 103 insertions(+), 32 deletions(-) diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs index d3ca0612..4b50cc38 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs @@ -90,7 +90,7 @@ public async Task RecoverUserGivenValidTokenReturnsTrue() // Act await using var context = new CoffeeCardContext(builder.Options, databaseSettings, environmentSettings); - var token = new Token("valid"); + var token = new Token("valid", TokenType.ResetPassword); var userTokens = new List { token }; var programme = new Programme { FullName = "fullName", ShortName = "shortName" }; @@ -142,7 +142,7 @@ public async Task RecoverUserGivenValidTokenUpdatesPasswordAndResetsUsersTokens( // Act await using var context = new CoffeeCardContext(builder.Options, databaseSettings, environmentSettings); - var token = new Token("valid"); + var token = new Token("valid", TokenType.ResetPassword); var userTokens = new List { token }; var programme = new Programme { FullName = "fullName", ShortName = "shortName" }; diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs index 3c9ddb31..a52c37b8 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs @@ -75,7 +75,7 @@ public async Task ValidateTokenGivenValidTokenReturnsTrue() var tokenService = new TokenService(_identity, claimsUtility); var token = tokenService.GenerateToken(claims); - var userTokens = new List { new Token(token) }; + var userTokens = new List { new Token(token, TokenType.MagicLink) }; var user = GenerateTestUser(tokens: userTokens); await context.AddAsync(user); await context.SaveChangesAsync(); @@ -151,7 +151,7 @@ public async Task ValidateTokenGivenWelformedExpiredTokenReturnsFalse() var token = new JwtSecurityTokenHandler().WriteToken(jwt); - var userTokens = new List { new Token(token) }; + var userTokens = new List { new Token(token, TokenType.MagicLink) }; var user = GenerateTestUser(tokens: userTokens); await context.AddAsync(user); await context.SaveChangesAsync(); diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs index 1201f4bd..757cc000 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs @@ -53,8 +53,13 @@ public async Task GetAccountByClaimsReturnsUserClaimWithEmail() context.Users.Add(expected); await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, new Mock().Object); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); result = await accountService.GetAccountByClaimsAsync(claims); // Assert @@ -73,8 +78,13 @@ public async Task GetAccountByClaimsThrowsApiExceptionGivenInvalidClaim(IEnumera context.Users.Add(validUser); await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, new Mock().Object); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); // Assert await Assert.ThrowsAsync(async () => await accountService.GetAccountByClaimsAsync(claims)); @@ -108,7 +118,7 @@ public async Task RegisterAccountReturnsUserOnValidInput(String name, String ema // Using same context across all valid users to test creation of multiple users using var context = CreateTestCoffeeCardContextWithName(nameof(RegisterAccountReturnsUserOnValidInput)); - var emailServiceMock = new Mock(); + var emailServiceMock = new Mock(); emailServiceMock.Setup(e => e.SendRegistrationVerificationEmailAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); var emailService = emailServiceMock.Object; @@ -120,8 +130,14 @@ public async Task RegisterAccountReturnsUserOnValidInput(String name, String ema context.Programmes.Add(programme); await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - emailService, hashService); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + emailService, + new Mock().Object, + new Mock().Object, + hashService); + result = await accountService.RegisterAccountAsync(name, email, password, programmeId); // Assert @@ -148,8 +164,13 @@ public async Task RegisterAccountThrowsApiExceptionWithStatus409OnExistingEmail( hashservice.Setup(h => h.Hash("pass")).Returns(""); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, hashservice.Object); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + hashservice.Object); // Assert // Register the first user @@ -166,8 +187,13 @@ public async Task RegisterAccountThrowsApiExceptionWithStatus400WhenGivenInvalid // Arrange using var context = CreateTestCoffeeCardContextWithName(nameof(RegisterAccountThrowsApiExceptionWithStatus400WhenGivenInvalidProgrammeId)); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, new Mock().Object); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); // Assert var exception = await Assert.ThrowsAsync( @@ -197,7 +223,7 @@ public async Task RegisterAccountSendsVerificationEmailOnlyValidInput() }; using var context = CreateTestCoffeeCardContextWithName(nameof(RegisterAccountSendsVerificationEmailOnlyValidInput)); - var emailServiceMock = new Mock(); + var emailServiceMock = new Mock(); emailServiceMock.Setup(e => e.SendRegistrationVerificationEmailAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); var emailService = emailServiceMock.Object; @@ -209,8 +235,14 @@ public async Task RegisterAccountSendsVerificationEmailOnlyValidInput() context.Programmes.Add(programme); await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - emailService, hashService); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + emailService, + new Mock().Object, + new Mock().Object, + hashService); + await accountService.RegisterAccountAsync("name", "email", "password", 1); // Assert @@ -275,8 +307,14 @@ public async Task UpdateAccountUpdatesAllNonNullProperties(String name, String e var hashService = hashServiceMock.Object; // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, hashService); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + hashService); + var result = await accountService.UpdateAccountAsync(user, updateUserRequest); // Assert @@ -322,8 +360,13 @@ public async Task UpdateAccountThrowsApiExceptionOnInvalidProgrammeId() await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - new Mock().Object, new Mock().Object); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); // Assert var exception = await Assert.ThrowsAsync(async () => await accountService.UpdateAccountAsync(user, updateUserRequest)); @@ -346,13 +389,18 @@ public async Task RequestAnonymizationSendsEmail() context.Users.Add(user); await context.SaveChangesAsync(); - var emailServiceMock = new Mock(); + var emailServiceMock = new Mock(); emailServiceMock.Setup(e => e.SendVerificationEmailForDeleteAccount(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); var emailService = emailServiceMock.Object; // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, - emailService, new Mock().Object); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + emailService, + new Mock().Object, + new Mock().Object, + new Mock().Object); await accountService.RequestAnonymizationAsync(user); // Assert @@ -389,12 +437,17 @@ public async Task AnonymizeAccountRemovesIdentifyableInformationFromUser() context.Users.Add(user); await context.SaveChangesAsync(); - var tokenServiceMock = new Mock(); + var tokenServiceMock = new Mock(); tokenServiceMock.Setup(e => e.ValidateVerificationTokenAndGetEmail("test")).Returns(userEmail); // Act - var accountService = new Library.Services.v2.AccountService(context, tokenServiceMock.Object, - new Mock().Object, new Mock().Object); + var accountService = new Library.Services.v2.AccountService( + context, + tokenServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); await accountService.AnonymizeAccountAsync("test"); var result = await context.Users.Where(u => u.Id == user.Id).FirstAsync(); @@ -431,8 +484,14 @@ public async Task ResendVerificationEmailWhenAccountIsNotVerified() await context.SaveChangesAsync(); // Act - var emailService = new Mock(); - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, emailService.Object, new Mock().Object); + var emailService = new Mock(); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + emailService.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); await accountService.ResendAccountVerificationEmail(new ResendAccountVerificationEmailRequest { @@ -465,7 +524,13 @@ public async Task ResendVerificationEmailThrowsConflictExceptionWhenAccountIsAlr await context.SaveChangesAsync(); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, new Mock().Object, new Mock().Object); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); await Assert.ThrowsAsync(async () => await accountService.ResendAccountVerificationEmail(new ResendAccountVerificationEmailRequest { @@ -480,7 +545,13 @@ public async Task ResendVerificationEmailThrowsEntityNotFoundExceptionWhenEmailD await using var context = CreateTestCoffeeCardContextWithName(nameof(ResendVerificationEmailThrowsEntityNotFoundExceptionWhenEmailDoesnotExist)); // Act - var accountService = new Library.Services.v2.AccountService(context, new Mock().Object, new Mock().Object, new Mock().Object); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); await Assert.ThrowsAsync(async () => await accountService.ResendAccountVerificationEmail(new ResendAccountVerificationEmailRequest { From 494472a29f788349de2c15296a13cc3d555f6190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 12 Nov 2024 19:13:03 +0100 Subject: [PATCH 16/37] Use new email sender --- .../Services/v2/EmailService.cs | 34 +++---------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs index c1dba24b..80494c01 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs @@ -18,12 +18,12 @@ public class EmailService : IEmailService { private readonly IWebHostEnvironment _env; private readonly EnvironmentSettings _environmentSettings; - private readonly MailgunSettings _mailgunSettings; + private readonly IEmailSender _emailSender; - public EmailService(MailgunSettings mailgunSettings, EnvironmentSettings environmentSettings, + public EmailService(IEmailSender emailSender, EnvironmentSettings environmentSettings, IWebHostEnvironment env) { - _mailgunSettings = mailgunSettings; + _emailSender = emailSender; _environmentSettings = environmentSettings; _env = env; } @@ -51,14 +51,14 @@ public async Task SendMagicLink(User user, string magicLink, LoginType loginType message.Body = builder.ToMessageBody(); - await SendEmailAsync(message); + await _emailSender.SendEmailAsync(message); } private static BodyBuilder BuildMagicLinkEmail(BodyBuilder builder, string email, string name, string deeplink) { builder.HtmlBody = builder.HtmlBody.Replace("{email}", email); builder.HtmlBody = builder.HtmlBody.Replace("{name}", name); - builder.HtmlBody = builder.HtmlBody.Replace("{expiry}", "30 minutes"); + builder.HtmlBody = builder.HtmlBody.Replace("{expires}", "30 minutes"); builder.HtmlBody = builder.HtmlBody.Replace("{deeplink}", deeplink); return builder; @@ -85,29 +85,5 @@ private BodyBuilder RetrieveTemplate(string templateName) return builder; } - - private async Task SendEmailAsync(MimeMessage mail) - { - var client = new RestClient(_mailgunSettings.MailgunApiUrl) - { - Authenticator = new HttpBasicAuthenticator("api", _mailgunSettings.ApiKey) - }; - - var request = new RestRequest(); - request.AddParameter("domain", _mailgunSettings.Domain, ParameterType.UrlSegment); - request.Resource = "{domain}/messages"; - request.AddParameter("from", "Cafe Analog "); - request.AddParameter("to", mail.To[0].ToString()); - request.AddParameter("subject", mail.Subject); - request.AddParameter("html", mail.HtmlBody); - request.Method = Method.Post; - - var response = await client.ExecutePostAsync(request); - - if (!response.IsSuccessful) - { - Log.Error("Error sending request to Mailgun. StatusCode: {statusCode} ErrorMessage: {errorMessage}", response.StatusCode, response.ErrorMessage); - } - } } } \ No newline at end of file From 689b2f5245112b4b43f1ceab2f6813864b294433 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Fri, 15 Nov 2024 12:06:20 +0100 Subject: [PATCH 17/37] Exclude generated emails from sonar analysis --- .github/workflows/sonarcloud.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index d6b63a88..559bc731 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -43,7 +43,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"AnalogIO_analog-core" /o:"analogio" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="coverageunit.xml,coverageintegration.xml" /d:sonar.exclusions="CoffeeCard.Library/Migrations/*" + .\.sonar\scanner\dotnet-sonarscanner begin /k:"AnalogIO_analog-core" /o:"analogio" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="coverageunit.xml,coverageintegration.xml" /d:sonar.exclusions="CoffeeCard.Library/Migrations/*,CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/*" dotnet tool install --global coverlet.console From 6b6cd10b7e32fd893d61636585ff70b8eeebf986 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Fri, 15 Nov 2024 16:04:44 +0100 Subject: [PATCH 18/37] Hashing tokens before saving to DB --- .../CoffeeCard.Library/Services/v2/ITokenService.cs | 2 +- .../CoffeeCard.Library/Services/v2/TokenService.cs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs index 0b136649..b208c346 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs @@ -7,6 +7,6 @@ public interface ITokenService { string GenerateMagicLink(User user); Task GenerateRefreshTokenAsync(User user); - Task GetValidTokenByHashAsync(string tokenHash); + Task GetValidTokenByHashAsync(string tokenString); } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index efca6d4f..683c5b29 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -13,10 +13,12 @@ namespace CoffeeCard.Library.Services.v2; public class TokenService : ITokenService { private readonly CoffeeCardContext _context; + private readonly IHashService _hashService; - public TokenService(CoffeeCardContext context) + public TokenService(CoffeeCardContext context, IHashService hashService) { _context = context; + _hashService = hashService; } public string GenerateMagicLink(User user) @@ -32,13 +34,15 @@ public string GenerateMagicLink(User user) public async Task GenerateRefreshTokenAsync(User user) { var refreshToken = Guid.NewGuid().ToString(); - _context.Tokens.Add(new Token(refreshToken, TokenType.Refresh) { User = user }); + var hashedToken = _hashService.Hash(refreshToken); + _context.Tokens.Add(new Token(hashedToken, TokenType.Refresh) { User = user }); await _context.SaveChangesAsync(); return refreshToken; } - public async Task GetValidTokenByHashAsync(string tokenHash) + public async Task GetValidTokenByHashAsync(string tokenString) { + var tokenHash = _hashService.Hash(tokenString); var foundToken = await _context.Tokens.Include(t => t.User).FirstOrDefaultAsync(t => t.TokenHash == tokenHash); if (foundToken == null || foundToken.Revoked || foundToken.Expired()) { From 61e3834da4a1fdf560ad5b337123b7fe977b23c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 19 Nov 2024 12:51:39 +0100 Subject: [PATCH 19/37] Try remove exclusions to use rules defined in UI --- .github/workflows/sonarcloud.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 559bc731..2074eb4e 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -14,7 +14,7 @@ jobs: uses: actions/setup-java@v4 with: java-version: 17 - distribution: 'zulu' + distribution: "zulu" - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -43,7 +43,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"AnalogIO_analog-core" /o:"analogio" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="coverageunit.xml,coverageintegration.xml" /d:sonar.exclusions="CoffeeCard.Library/Migrations/*,CoffeeCard.WebApi/wwwroot/Templates/EmailTemplate/GeneratedEmails/*" + .\.sonar\scanner\dotnet-sonarscanner begin /k:"AnalogIO_analog-core" /o:"analogio" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="coverageunit.xml,coverageintegration.xml" dotnet tool install --global coverlet.console @@ -52,4 +52,5 @@ jobs: coverlet ./coffeecard/CoffeeCard.Tests.Unit/bin/Debug/net8.0/CoffeeCard.Tests.Unit.dll --target "dotnet" --targetargs "test --no-build coffeecard/CoffeeCard.Tests.Unit" -f=opencover -o="coverageunit.xml" coverlet ./coffeecard/CoffeeCard.Tests.Integration/bin/Debug/net8.0/CoffeeCard.Tests.Integration.dll --target "dotnet" --targetargs "test --no-build coffeecard/CoffeeCard.Tests.Integration" -f=opencover -o="coverageintegration.xml" - .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" \ No newline at end of file + .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" + From 66cad307dfc0bb3755e5d44f690ed4616c174a87 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 19 Nov 2024 19:47:15 +0100 Subject: [PATCH 20/37] Service tests --- .../Services/v2/AccountServiceTest.cs | 137 ++++++++++ .../Services/v2/TokenServiceTests.cs | 248 ++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs index 757cc000..ee0f4a22 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs @@ -559,6 +559,143 @@ await Assert.ThrowsAsync(async () => await accountServi })); } + [Fact(DisplayName = "SendMagicLink sends email when user is found")] + public async Task SendMagicLinkSendsEmailWhenUserIsFound() + { + // Arrange + const string userEmail = "john@cena.com"; + var user = new User + { + Id = 1, + Name = "John Cena", + Password = "pass", + Email = userEmail, + }; + + await using var context = CreateTestCoffeeCardContextWithName(nameof(SendMagicLinkSendsEmailWhenUserIsFound)); + + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + var emailService = new Mock(); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + emailService.Object, + new Mock().Object, + new Mock().Object); + + // Act + await accountService.SendMagicLinkEmail(userEmail, LoginType.Shifty); + + // Assert + emailService.Verify(e => e.SendMagicLink(user, It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact(DisplayName = "SendMagicLink does not send mail when user is not found")] + public async Task SendMagicLinkDoesNotSendMailWhenUserIsNotFound() + { + // Arrange + await using var context = CreateTestCoffeeCardContextWithName(nameof(SendMagicLinkDoesNotSendMailWhenUserIsNotFound)); + + var emailService = new Mock(); + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + emailService.Object, + new Mock().Object, + new Mock().Object); + + // Act + await accountService.SendMagicLinkEmail("nonexisting@email.com", LoginType.Shifty); + + // Assert + emailService.Verify(e => e.SendMagicLink(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact(DisplayName = "GenerateTokenPair revokes token on use")] + public async Task GenerateTokenPairRevokesTokenOnUse() + { + // Arrange + var user = new User + { + Id = 1, + Name = "John Cena", + Password = "pass", + Email = "test@test.com", + }; + + var refreshToken = new Token("refreshToken", TokenType.Refresh) { User = user }; + + await using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateTokenPairRevokesTokenOnUse)); + await context.Users.AddAsync(user); + await context.Tokens.AddAsync(refreshToken); + await context.SaveChangesAsync(); + + var tokenService = new Mock(); + // tokenService.Setup(t => t.GenerateRefreshTokenAsync(user)).ReturnsAsync("refreshToken"); + tokenService.Setup(t => t.GetValidTokenByHashAsync("refreshToken")).ReturnsAsync(refreshToken); + + var accountService = new Library.Services.v2.AccountService( + context, + new Mock().Object, + new Mock().Object, + new Mock().Object, + tokenService.Object, + new Mock().Object); + + // Act + var tokenPair = await accountService.GenerateTokenPair("refreshToken"); + + // Assert + Assert.True(refreshToken.Revoked); + } + + [Fact(DisplayName = "GenerateTokenPair returns token pair")] + public async Task GenerateTokenPairReturnsTokenPair() + { + // Arrange + var user = new User + { + Id = 1, + Name = "John Cena", + Password = "pass", + Email = "test@test.com", + }; + + var refreshToken = new Token("refreshToken", TokenType.Refresh) { User = user }; + + await using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateTokenPairReturnsTokenPair)); + await context.Users.AddAsync(user); + await context.Tokens.AddAsync(refreshToken); + await context.SaveChangesAsync(); + + var tokenServicev2 = new Mock(); + tokenServicev2.Setup(t => t.GenerateRefreshTokenAsync(user)).ReturnsAsync("newToken"); + tokenServicev2.Setup(t => t.GetValidTokenByHashAsync("refreshToken")).ReturnsAsync(refreshToken); + + var tokenServicev1 = new Mock(); + tokenServicev1.Setup(t => t.GenerateToken(It.IsAny>())).Returns("jwtToken"); + + var accountService = new Library.Services.v2.AccountService( + context, + tokenServicev1.Object, + new Mock().Object, + new Mock().Object, + tokenServicev2.Object, + new Mock().Object); + + // Act + var tokenPair = await accountService.GenerateTokenPair("refreshToken"); + + // Assert + Assert.NotNull(tokenPair); + Assert.NotNull(tokenPair.RefreshToken); + Assert.NotNull(tokenPair.Jwt); + } + public static IEnumerable ClaimGenerator() { yield return new object[] { diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs new file mode 100644 index 00000000..77b1da75 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using CoffeeCard.Common.Configuration; +using CoffeeCard.Common.Errors; +using CoffeeCard.Library.Persistence; +using CoffeeCard.Library.Services; +using CoffeeCard.Library.Services.v2; +using CoffeeCard.Models.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Moq; +using Xunit; +using TokenService = CoffeeCard.Library.Services.v2.TokenService; + +namespace CoffeeCard.Tests.Unit.Services.v2 +{ + public class TokenServiceTests + { + private CoffeeCardContext CreateTestCoffeeCardContextWithName(string name) + { + var builder = new DbContextOptionsBuilder() + .UseInMemoryDatabase(name); + + var databaseSettings = new DatabaseSettings + { + SchemaName = "test" + }; + var environmentSettings = new EnvironmentSettings() + { + EnvironmentType = EnvironmentType.Test + }; + + return new CoffeeCardContext(builder.Options, databaseSettings, environmentSettings); + } + + [Fact(DisplayName = "GenerateMagicLink returns a link with a valid token for user")] + public async Task GenerateMagicLink_ReturnsLinkWithValidTokenForUser() + { + // Arrange + var user = new User + { + Id = 1, + Email = "test@test.com", + Name = "Test User" + }; + + using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateMagicLink_ReturnsLinkWithValidTokenForUser)); + + context.Users.Add(user); + await context.SaveChangesAsync(); + + var tokenService = new TokenService(context, Mock.Of()); + + // Act + var result = tokenService.GenerateMagicLink(user); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Contains(user.Tokens, t => t.TokenHash == result); + var token = user.Tokens.First(t => t.TokenHash == result); + Assert.Equal(TokenType.MagicLink, token.Type); + Assert.False(token.Revoked, "Token should not be revoked"); + } + + [Fact(DisplayName = "GenerateRefreshTokenAsync returns a valid token for user")] + public async Task GenerateRefreshTokenAsync_ReturnsValidTokenForUser() + { + // Arrange + var user = new User + { + Id = 1, + Email = "test@test.com", + Name = "Test User" + }; + + using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateRefreshTokenAsync_ReturnsValidTokenForUser)); + + context.Users.Add(user); + await context.SaveChangesAsync(); + var hashService = new HashService(); + + var tokenService = new TokenService(context, hashService); + + // Act + var result = await tokenService.GenerateRefreshTokenAsync(user); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + // We cannot assert on tokenHash since it has been hashed for security reasons. Hashing is tested elsewhere. + var token = user.Tokens.First(); + Assert.Equal(TokenType.Refresh, token.Type); + Assert.False(token.Revoked, "Token should not be revoked"); + } + + [Fact(DisplayName = "GetValidTokenByHashAsync throws exception if token does not exist")] + public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenDoesNotExist() + { + // Arrange + using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_ThrowsExceptionIfTokenDoesNotExist)); + + var tokenService = new TokenService(context, Mock.Of()); + + // Act & Assert + await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync("token")); + } + + [Fact(DisplayName = "GetValidTokenByHashAsync throws exception if token is revoked")] + public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenIsRevoked() + { + // Arrange + var user = new User + { + Id = 1, + Email = "test@test.com", + Name = "Test User" + }; + + using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_ThrowsExceptionIfTokenIsRevoked)); + + context.Users.Add(user); + + var token = new Token("token", TokenType.Refresh) { User = user }; + context.Tokens.Add(token); + + token.Revoked = true; + await context.SaveChangesAsync(); + + var tokenService = new TokenService(context, Mock.Of()); + + // Act & Assert + await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync("token")); + } + + [Fact(DisplayName = "GetValidTokenByHashAsync throws exception if token has expired")] + public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenHasExpired() + { + // Arrange + var user = new User + { + Id = 1, + Email = "test@test.com", + Name = "Test User" + }; + + using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_ThrowsExceptionIfTokenHasExpired)); + + context.Users.Add(user); + + var token = new Token("token", TokenType.Refresh) { User = user, Expires = DateTime.Now.AddDays(-1) }; + context.Tokens.Add(token); + + await context.SaveChangesAsync(); + + var tokenService = new TokenService(context, Mock.Of()); + + // Act & Assert + await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync("token")); + } + + [Fact(DisplayName = "GetValidTokenByHashAsync returns token by valid hash")] + public async Task GetValidTokenByHashAsync_ReturnsTokenByValidHash() + { + // Arrange + var user = new User + { + Id = 1, + Email = "test@test.com", + Name = "Test User" + }; + + using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_ReturnsTokenByValidHash)); + + context.Users.Add(user); + + var token = new Token("token", TokenType.Refresh) { User = user }; + context.Tokens.Add(token); + + await context.SaveChangesAsync(); + + var hashService = new Mock(); + hashService.Setup(h => h.Hash(It.IsAny())).Returns("token"); + + var tokenService = new TokenService(context, hashService.Object); + + // Act & Assert + var result = await tokenService.GetValidTokenByHashAsync("token"); + + // Assert + Assert.NotNull(result); + Assert.Equal(token, result); + Assert.False(result.Revoked); + Assert.False(result.Expired()); + } + + [Fact(DisplayName = "GetValidTokenByHashAsync invalidates users refresh tokens if token is invalid")] + public async Task GetValidTokenByHashAsync_InvalidatesUsersRefreshTokensIfTokenIsInvalid() + { + // Arrange + var user = new User + { + Id = 1, + Email = "test@test.com", + Name = "Test User" + }; + + using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_InvalidatesUsersRefreshTokensIfTokenIsInvalid)); + + context.Users.Add(user); + + var token = new Token("token", TokenType.Refresh) { User = user, Revoked = true }; + var refreshToken = new Token("refresh", TokenType.Refresh) { User = user }; + + Token[] otherTokens = + { + new ("magicLink", TokenType.MagicLink) {User = user}, + new ("reset", TokenType.ResetPassword) {User = user}, + }; + + context.Tokens.AddRange(token, refreshToken); + context.Tokens.AddRange(otherTokens); + + await context.SaveChangesAsync(); + + var hashService = new Mock(); + hashService.Setup(h => h.Hash(It.IsAny())).Returns("token"); + + var tokenService = new TokenService(context, hashService.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync("token")); + + // Assert + Assert.True(refreshToken.Revoked); + foreach (var otherToken in otherTokens) + { + Assert.False(otherToken.Revoked); + } + } + } +} From 9134cb4a6f51bf712b86864515e1f190e2505c1f Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 19 Nov 2024 20:41:58 +0100 Subject: [PATCH 21/37] Remove unused imports --- coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs | 7 ------- coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs | 2 -- coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs | 2 -- .../CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs | 7 ------- 4 files changed, 18 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs index 80494c01..0489f471 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs @@ -2,15 +2,10 @@ using System.IO; using System.Threading.Tasks; using CoffeeCard.Common.Configuration; -using CoffeeCard.Models.DataTransferObjects.Purchase; -using CoffeeCard.Models.DataTransferObjects.User; using CoffeeCard.Models.Entities; using Microsoft.AspNetCore.Hosting; using MimeKit; -using RestSharp; -using RestSharp.Authenticators; using Serilog; -using TimeZoneConverter; namespace CoffeeCard.Library.Services.v2 { @@ -42,8 +37,6 @@ public async Task SendMagicLink(User user, string magicLink, LoginType loginType var deeplink = loginType.GetDeepLink(baseUrl, magicLink); - Console.WriteLine($"MAGIC LINK HREF: {deeplink}"); - builder = BuildMagicLinkEmail(builder, user.Email, user.Name, deeplink); message.To.Add(new MailboxAddress(user.Name, user.Email)); diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs index e075256e..721eb903 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IEmailService.cs @@ -1,6 +1,4 @@ using System.Threading.Tasks; -using CoffeeCard.Models.DataTransferObjects.Purchase; -using CoffeeCard.Models.DataTransferObjects.User; using CoffeeCard.Models.Entities; namespace CoffeeCard.Library.Services.v2 diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index 683c5b29..37b99f49 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -1,10 +1,8 @@ using System; using System.Linq; using System.Threading.Tasks; -using CoffeeCard.Common.Configuration; using CoffeeCard.Common.Errors; using CoffeeCard.Library.Persistence; -using CoffeeCard.Library.Utils; using CoffeeCard.Models.Entities; using Microsoft.EntityFrameworkCore; diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs index 77b1da75..2dbf976a 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs @@ -1,19 +1,12 @@ using System; -using System.Collections.Generic; -using System.IdentityModel.Tokens.Jwt; using System.Linq; -using System.Security.Claims; -using System.Text; using System.Threading.Tasks; using CoffeeCard.Common.Configuration; using CoffeeCard.Common.Errors; using CoffeeCard.Library.Persistence; using CoffeeCard.Library.Services; -using CoffeeCard.Library.Services.v2; using CoffeeCard.Models.Entities; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; using Moq; using Xunit; using TokenService = CoffeeCard.Library.Services.v2.TokenService; From c60bbf7d570c1b56a201c0458781790006133b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 26 Nov 2024 18:38:29 +0100 Subject: [PATCH 22/37] Fix asynchronity of GenerateMagicLink --- coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs | 2 +- coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs | 2 +- coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs | 4 ++-- .../CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index 224f3a98..9ca9065a 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -311,7 +311,7 @@ public async Task SendMagicLinkEmail(string email, LoginType loginType) // Should not throw error to prevent showing a malicious user if an email is already registered return; } - var magicLinkTokenHash = _tokenServiceV2.GenerateMagicLink(user); + var magicLinkTokenHash = await _tokenServiceV2.GenerateMagicLink(user); await _emailServiceV2.SendMagicLink(user, magicLinkTokenHash, loginType); } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs index b208c346..db759d9a 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs @@ -5,7 +5,7 @@ namespace CoffeeCard.Library.Services.v2 { public interface ITokenService { - string GenerateMagicLink(User user); + Task GenerateMagicLink(User user); Task GenerateRefreshTokenAsync(User user); Task GetValidTokenByHashAsync(string tokenString); } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index 37b99f49..f8603083 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -19,13 +19,13 @@ public TokenService(CoffeeCardContext context, IHashService hashService) _hashService = hashService; } - public string GenerateMagicLink(User user) + public async Task GenerateMagicLink(User user) { var guid = Guid.NewGuid().ToString(); var magicLinkToken = new Token(guid, TokenType.MagicLink); user.Tokens.Add(magicLinkToken); - _context.SaveChangesAsync(); + await _context.SaveChangesAsync(); return magicLinkToken.TokenHash; } diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs index 2dbf976a..9740c175 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs @@ -51,7 +51,7 @@ public async Task GenerateMagicLink_ReturnsLinkWithValidTokenForUser() var tokenService = new TokenService(context, Mock.Of()); // Act - var result = tokenService.GenerateMagicLink(user); + var result = await tokenService.GenerateMagicLink(user); // Assert Assert.NotNull(result); From c175b82c8e42d5a0276211d1b8b585fc0d338862 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 26 Nov 2024 22:37:37 +0100 Subject: [PATCH 23/37] Fix data annotations and examples --- .../DataTransferObjects/v2/User/UserLoginRequest.cs | 2 ++ .../DataTransferObjects/v2/User/UserLoginResponse.cs | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs index c53e017d..7fce8fec 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginRequest.cs @@ -19,6 +19,7 @@ public class UserLoginRequest /// Email /// john@doe.com [EmailAddress] + [Required] public string Email { get; set; } = null!; /// @@ -26,6 +27,7 @@ public class UserLoginRequest /// /// LoginType /// Shifty + [Required] public LoginType LoginType { get; set; } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs index c6357226..69b62192 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/User/UserLoginResponse.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace CoffeeCard.Models.DataTransferObjects.v2.User { /// @@ -5,8 +7,8 @@ namespace CoffeeCard.Models.DataTransferObjects.v2.User /// /// /// { - /// "jwt": "hidden", - /// "refreshToken": "hidden" + /// "jwt": "[no example provided]", + /// "refreshToken": "[no example provided]" /// } /// public class UserLoginResponse @@ -14,11 +16,13 @@ public class UserLoginResponse /// /// JSON Web Token with claims for the user logging in /// + [Required] public required string Jwt { get; set; } /// /// Token used to obtain a new JWT token on expiration /// + [Required] public required string RefreshToken { get; set; } } } From 3cac5a86d2c2b70db2bae4fb0c9a223d97157e19 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 26 Nov 2024 22:37:48 +0100 Subject: [PATCH 24/37] Add TokenHash index --- ...241126213412_AddTokenHashIndex.Designer.cs | 668 ++++++++++++++++++ .../20241126213412_AddTokenHashIndex.cs | 47 ++ .../CoffeeCardContextModelSnapshot.cs | 4 +- .../CoffeeCard.Models/Entities/Token.cs | 2 + 4 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.Designer.cs create mode 100644 coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.cs diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.Designer.cs b/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.Designer.cs new file mode 100644 index 00000000..9507f06f --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.Designer.cs @@ -0,0 +1,668 @@ +// +using System; +using CoffeeCard.Library.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CoffeeCard.Library.Migrations +{ + [DbContext(typeof(CoffeeCardContext))] + [Migration("20241126213412_AddTokenHashIndex")] + partial class AddTokenHashIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("dbo") + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CoffeeCard.Models.Entities.LoginAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Time") + .HasColumnType("datetime2"); + + b.Property("User_Id") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("User_Id"); + + b.ToTable("LoginAttempts", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItemProduct", b => + { + b.Property("MenuItemId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.HasKey("MenuItemId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("MenuItemProducts", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.PosPurhase", b => + { + b.Property("PurchaseId") + .HasColumnType("int"); + + b.Property("BaristaInitials") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("PurchaseId"); + + b.ToTable("PosPurchases", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExperienceWorth") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NumberOfTickets") + .HasColumnType("int"); + + b.Property("Price") + .HasColumnType("int"); + + b.Property("Visible") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Products", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.ProductUserGroup", b => + { + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("UserGroup") + .HasColumnType("int"); + + b.HasKey("ProductId", "UserGroup"); + + b.ToTable("ProductUserGroups", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Programme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FullName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ShortName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SortPriority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Programmes", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("ExternalTransactionId") + .HasColumnType("nvarchar(450)"); + + b.Property("NumberOfTickets") + .HasColumnType("int"); + + b.Property("OrderId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Price") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PurchasedById") + .HasColumnType("int") + .HasColumnName("PurchasedBy_Id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalTransactionId"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("PurchasedById"); + + b.ToTable("Purchases", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Statistic", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExpiryDate") + .HasColumnType("datetime2"); + + b.Property("LastSwipe") + .HasColumnType("datetime2"); + + b.Property("Preset") + .HasColumnType("int"); + + b.Property("SwipeCount") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Preset", "ExpiryDate"); + + b.ToTable("Statistics", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUsed") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("OwnerId") + .HasColumnType("int") + .HasColumnName("Owner_Id"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("PurchaseId") + .HasColumnType("int") + .HasColumnName("Purchase_Id"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UsedOnMenuItemId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("UsedOnMenuItemId"); + + b.ToTable("Tickets", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Token", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Expires") + .HasColumnType("datetime2"); + + b.Property("Revoked") + .HasColumnType("bit"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("Tokens", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUpdated") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Experience") + .HasColumnType("int"); + + b.Property("IsVerified") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Password") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacyActivated") + .HasColumnType("bit"); + + b.Property("ProgrammeId") + .HasColumnType("int") + .HasColumnName("Programme_Id"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserGroup") + .HasColumnType("int"); + + b.Property("UserState") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("Name"); + + b.HasIndex("ProgrammeId"); + + b.HasIndex("UserGroup"); + + b.ToTable("Users", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Voucher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUsed") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ProductId") + .HasColumnType("int") + .HasColumnName("Product_Id"); + + b.Property("PurchaseId") + .HasColumnType("int"); + + b.Property("Requester") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("UserId"); + + b.ToTable("Vouchers", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.WebhookConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("LastUpdated") + .HasColumnType("datetime2"); + + b.Property("SignatureKey") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WebhookConfigurations", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.LoginAttempt", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("LoginAttempts") + .HasForeignKey("User_Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItemProduct", b => + { + b.HasOne("CoffeeCard.Models.Entities.MenuItem", "MenuItem") + .WithMany("MenuItemProducts") + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany("MenuItemProducts") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MenuItem"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.PosPurhase", b => + { + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Purchase"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.ProductUserGroup", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany("ProductUserGroup") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.User", "PurchasedBy") + .WithMany("Purchases") + .HasForeignKey("PurchasedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("PurchasedBy"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Statistic", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("Statistics") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Ticket", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "Owner") + .WithMany("Tickets") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany("Tickets") + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.MenuItem", "UsedOnMenuItem") + .WithMany() + .HasForeignKey("UsedOnMenuItemId"); + + b.Navigation("Owner"); + + b.Navigation("Purchase"); + + b.Navigation("UsedOnMenuItem"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Token", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.HasOne("CoffeeCard.Models.Entities.Programme", "Programme") + .WithMany("Users") + .HasForeignKey("ProgrammeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Programme"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Voucher", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId"); + + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Product"); + + b.Navigation("Purchase"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItem", b => + { + b.Navigation("MenuItemProducts"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Product", b => + { + b.Navigation("MenuItemProducts"); + + b.Navigation("ProductUserGroup"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Programme", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.Navigation("LoginAttempts"); + + b.Navigation("Purchases"); + + b.Navigation("Statistics"); + + b.Navigation("Tickets"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.cs b/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.cs new file mode 100644 index 00000000..eced786b --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Migrations/20241126213412_AddTokenHashIndex.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CoffeeCard.Library.Migrations +{ + /// + public partial class AddTokenHashIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "TokenHash", + schema: "dbo", + table: "Tokens", + type: "nvarchar(450)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.CreateIndex( + name: "IX_Tokens_TokenHash", + schema: "dbo", + table: "Tokens", + column: "TokenHash"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tokens_TokenHash", + schema: "dbo", + table: "Tokens"); + + migrationBuilder.AlterColumn( + name: "TokenHash", + schema: "dbo", + table: "Tokens", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(450)"); + } + } +} diff --git a/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs b/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs index eb9d19d7..f343108d 100644 --- a/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs +++ b/coffeecard/CoffeeCard.Library/Migrations/CoffeeCardContextModelSnapshot.cs @@ -317,7 +317,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("TokenHash") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasColumnType("nvarchar(450)"); b.Property("Type") .IsRequired() @@ -329,6 +329,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("TokenHash"); + b.HasIndex("UserId"); b.ToTable("Tokens", "dbo"); diff --git a/coffeecard/CoffeeCard.Models/Entities/Token.cs b/coffeecard/CoffeeCard.Models/Entities/Token.cs index 464c6787..c4546f45 100644 --- a/coffeecard/CoffeeCard.Models/Entities/Token.cs +++ b/coffeecard/CoffeeCard.Models/Entities/Token.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; namespace CoffeeCard.Models.Entities { @@ -8,6 +9,7 @@ namespace CoffeeCard.Models.Entities /// /// Hash used to identify the token /// + [Index(nameof(TokenHash))] public class Token(string tokenHash, TokenType type) { /// From 01bf40b1718afbc3dcd18bacff0a87f501948f56 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 26 Nov 2024 23:03:35 +0100 Subject: [PATCH 25/37] Renaming --- coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs | 2 +- coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs | 2 +- .../CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs | 4 ++-- .../CoffeeCard.WebApi/Controllers/v2/AccountController.cs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index 9ca9065a..44ae2a77 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -315,7 +315,7 @@ public async Task SendMagicLinkEmail(string email, LoginType loginType) await _emailServiceV2.SendMagicLink(user, magicLinkTokenHash, loginType); } - public async Task GenerateTokenPair(string token) + public async Task GenerateUserLoginFromToken(string token) { // Validate token in DB var foundToken = await _tokenServiceV2.GetValidTokenByHashAsync(token); diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs index 40d18d2a..f768de13 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs @@ -78,7 +78,7 @@ public interface IAccountService /// Task UpdatePriviligedUserGroups(WebhookUpdateUserGroupRequest request); - Task GenerateTokenPair(string token); + Task GenerateUserLoginFromToken(string token); Task SendMagicLinkEmail(string email, LoginType loginType); } diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs index ee0f4a22..f5a022b8 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs @@ -647,7 +647,7 @@ public async Task GenerateTokenPairRevokesTokenOnUse() new Mock().Object); // Act - var tokenPair = await accountService.GenerateTokenPair("refreshToken"); + var tokenPair = await accountService.GenerateUserLoginFromToken("refreshToken"); // Assert Assert.True(refreshToken.Revoked); @@ -688,7 +688,7 @@ public async Task GenerateTokenPairReturnsTokenPair() new Mock().Object); // Act - var tokenPair = await accountService.GenerateTokenPair("refreshToken"); + var tokenPair = await accountService.GenerateUserLoginFromToken("refreshToken"); // Assert Assert.NotNull(tokenPair); diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index 80fc6fd0..a5a62d7f 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -263,7 +263,7 @@ public async Task> Authenticate(string tokenHash if (tokenHash is null) return NotFound(new MessageResponseDto { Message = "Token required for app authentication." }); - var token = await _accountService.GenerateTokenPair(tokenHash); + var token = await _accountService.GenerateUserLoginFromToken(tokenHash); return Ok(token); } } From df84c525f7f443c15fff3cb28d84e0a58f8eee01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 3 Dec 2024 16:09:51 +0100 Subject: [PATCH 26/37] Include shifty url in deployment appsettings --- coffeecard/CoffeeCard.WebApi/appsettings.json | 2 +- infrastructure/bicepconfig.json | 2 +- infrastructure/dev.settings.json | 7 ++++++- infrastructure/prd.settings.json | 7 ++++++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/coffeecard/CoffeeCard.WebApi/appsettings.json b/coffeecard/CoffeeCard.WebApi/appsettings.json index 7954b2bd..d2945648 100644 --- a/coffeecard/CoffeeCard.WebApi/appsettings.json +++ b/coffeecard/CoffeeCard.WebApi/appsettings.json @@ -69,4 +69,4 @@ "FeatureManagement": { "MobilePayManageWebhookRegistration": false } -} \ No newline at end of file +} diff --git a/infrastructure/bicepconfig.json b/infrastructure/bicepconfig.json index 8b4eae54..796f8349 100644 --- a/infrastructure/bicepconfig.json +++ b/infrastructure/bicepconfig.json @@ -40,4 +40,4 @@ } } } -} \ No newline at end of file +} diff --git a/infrastructure/dev.settings.json b/infrastructure/dev.settings.json index 230341b1..75191726 100644 --- a/infrastructure/dev.settings.json +++ b/infrastructure/dev.settings.json @@ -21,6 +21,10 @@ "name": "EnvironmentSettings__DeploymentUrl", "value": "https://core.dev.analogio.dk/" }, + { + "name": "EnvironmentSettings__ShiftyUrl", + "value": "https://shifty.dev.analogio.dk/" + }, { "name": "MailgunSettings__Domain", "value": "mg.cafeanalog.dk" @@ -89,4 +93,5 @@ } ] } -} \ No newline at end of file +} + diff --git a/infrastructure/prd.settings.json b/infrastructure/prd.settings.json index 1f0aa0f9..31d0f15d 100644 --- a/infrastructure/prd.settings.json +++ b/infrastructure/prd.settings.json @@ -21,6 +21,10 @@ "name": "EnvironmentSettings__DeploymentUrl", "value": "https://core.prd.analogio.dk/" }, + { + "name": "EnvironmentSettings__ShiftyUrl", + "value": "https://shifty.prd.analogio.dk/" + }, { "name": "MailgunSettings__Domain", "value": "mg.cafeanalog.dk" @@ -89,4 +93,5 @@ } ] } -} \ No newline at end of file +} + From 6f79570d8ed9ae79b4af17c22898438a32563374 Mon Sep 17 00:00:00 2001 From: Andreas Guldborg Hansen Date: Tue, 3 Dec 2024 19:23:32 +0100 Subject: [PATCH 27/37] Use builders for token and account v2 service tests (#304) This pull requests addresses @TTA777 review on #289 wrt using test data builders. This PR ensures we use builders for our models during testing. Though for our tokens we need to give it our custom instantiator, as we would like to keep the property of having the Token type require the two parameters token hash and token type and using this information to set the default expired `DateTime`, but still allowing us to overwrite this time for multiple purposes (testing, specific purposes at a later stage etc.). This PR also introduces integration tests for the new magic link authentication flow, only mocking the actual `IEmailSender`. --- .../CoffeeCard.Models/Entities/Token.cs | 3 +- .../Builders/TokenBuilder.cs | 11 +- .../CoffeeCard.Tests.Integration.csproj | 5 +- .../Controllers/v2/LoginTest.cs | 87 +++++++ .../WebApplication/BaseIntegrationTest.cs | 24 +- .../Services/v2/AccountServiceTest.cs | 220 ++++++------------ .../Services/v2/TokenServiceTests.cs | 70 ++---- 7 files changed, 198 insertions(+), 222 deletions(-) create mode 100644 coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs diff --git a/coffeecard/CoffeeCard.Models/Entities/Token.cs b/coffeecard/CoffeeCard.Models/Entities/Token.cs index c4546f45..06134ec4 100644 --- a/coffeecard/CoffeeCard.Models/Entities/Token.cs +++ b/coffeecard/CoffeeCard.Models/Entities/Token.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore; @@ -7,8 +8,6 @@ namespace CoffeeCard.Models.Entities /// /// Shared Token class for different token types /// - /// Hash used to identify the token - /// [Index(nameof(TokenHash))] public class Token(string tokenHash, TokenType type) { diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/TokenBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/TokenBuilder.cs index fb6d090b..0b510d86 100644 --- a/coffeecard/CoffeeCard.Tests.Common/Builders/TokenBuilder.cs +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/TokenBuilder.cs @@ -7,8 +7,15 @@ public partial class TokenBuilder { public static TokenBuilder Simple() { - return new TokenBuilder() - .WithUser(UserBuilder.Simple().Build()); + var builder = new TokenBuilder(); + builder.Faker.CustomInstantiator(f => + new Token(f.Random.Guid().ToString(), TokenType.Refresh)) + .Ignore(t => t.TokenHash) + .Ignore(t => t.Type); + return builder + .WithExpires(DateTime.Now.AddDays(1)) + .WithRevoked(false) + .WithUser(UserBuilder.DefaultCustomer().Build()); } public static TokenBuilder Typical() diff --git a/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj b/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj index d4d7297c..fd58ab58 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj +++ b/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj @@ -11,6 +11,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -43,12 +44,12 @@ - + CoffeeCardClient CoffeeCard.Tests.ApiClient.Generated /UseBaseUrl:false /OperationGenerationMode:SingleClientFromOperationId - + CoffeeCardClientV2 CoffeeCard.Tests.ApiClient.v2.Generated /AdditionalNamespaceUsages:CoffeeCard.Tests.ApiClient.Generated /UseBaseUrl:false /OperationGenerationMode:SingleClientFromOperationId diff --git a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs new file mode 100644 index 00000000..5f233d54 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs @@ -0,0 +1,87 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Threading.Tasks; +using CoffeeCard.Library.Services; +using CoffeeCard.Models.Entities; +using CoffeeCard.Tests.ApiClient.v2.Generated; +using CoffeeCard.Tests.Common.Builders; +using CoffeeCard.Tests.Integration.WebApplication; +using CoffeeCard.WebApi; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using MimeKit; +using Moq; +using Xunit; +using LoginType = CoffeeCard.Tests.ApiClient.v2.Generated.LoginType; + +namespace CoffeeCard.Tests.Integration.Controllers.v2.Account +{ + + public class LoginTest(CustomWebApplicationFactory factory) : BaseIntegrationTest(factory) + { + [Fact] + public async Task Unknown_user_login_doesnt_fail_but_no_token_is_created() + { + // It should not be possible to determine if account with email exists, thus never fail + var loginRequest = new UserLoginRequest + { + Email = "test@email.dk", + LoginType = LoginType.Shifty, + }; + + var exception = await Record.ExceptionAsync(async () => await CoffeeCardClientV2.Account_LoginAsync(loginRequest)); + Assert.Null(exception); + Assert.Empty(Context.Tokens); + } + + [Fact] + public async Task Known_user_login_saves_token_in_database_and_sends_one_mail() + { + Mock emailSenderMock = new Mock(); + ConfigureMockService(emailSenderMock.Object); + + var user = UserBuilder.DefaultCustomer().Build(); + + await Context.Users.AddAsync(user); + await Context.SaveChangesAsync(); + + var loginRequest = new UserLoginRequest + { + Email = user.Email, + LoginType = LoginType.Shifty, + }; + + await CoffeeCardClientV2.Account_LoginAsync(loginRequest); + + var token = await Context.Tokens.FirstOrDefaultAsync(); + + Assert.NotNull(token); + Assert.Equal(TokenType.MagicLink, token.Type); + Assert.Equal(user.Id, token.UserId); + + emailSenderMock.Verify(x => x.SendEmailAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Known_user_login_succeeds_returns_token() + { + var user = UserBuilder.DefaultCustomer().Build(); + var token = TokenBuilder.Simple().WithUser(user).WithType(TokenType.MagicLink).Build(); + var tokenString = token.TokenHash; + var tokenHash = Context.GetService().Hash(token.TokenHash); + token.TokenHash = tokenHash; // We need to hash the token before adding it to the database to ensure we don't leak credentials if database is breached + + await Context.Users.AddAsync(user); + await Context.Tokens.AddAsync(token); + await Context.SaveChangesAsync(); + + // We authenticate using the non-hashed token and let the backend hash the string for us + var response = await CoffeeCardClientV2.Account_AuthenticateAsync(tokenString); + + Assert.NotNull(response.Jwt); + Assert.NotNull(response.RefreshToken); + var tokenValidator = new JwtSecurityTokenHandler(); + Assert.True(tokenValidator.CanReadToken(response.Jwt)); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs index 3a774bed..cdc28217 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs +++ b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; +using System.Linq; using System.Net.Http; using System.Security.Claims; using System.Text; @@ -12,6 +13,7 @@ using CoffeeCard.Tests.ApiClient.v2.Generated; using CoffeeCard.Tests.Common.Builders; using CoffeeCard.WebApi; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using Xunit; @@ -23,9 +25,9 @@ public abstract class BaseIntegrationTest : CustomWebApplicationFactory { private readonly CustomWebApplicationFactory _factory; private readonly IServiceScope _scope; - private readonly HttpClient _httpClient; - protected readonly CoffeeCardClient CoffeeCardClient; - protected readonly CoffeeCardClientV2 CoffeeCardClientV2; + private HttpClient _httpClient; + protected CoffeeCardClient CoffeeCardClient => new(_httpClient); + protected CoffeeCardClientV2 CoffeeCardClientV2 => new(_httpClient); protected readonly CoffeeCardContext Context; protected BaseIntegrationTest(CustomWebApplicationFactory factory) @@ -38,8 +40,8 @@ protected BaseIntegrationTest(CustomWebApplicationFactory factory) _scope = _factory.Services.CreateScope(); _httpClient = GetHttpClient(); - CoffeeCardClient = new CoffeeCardClient(_httpClient); - CoffeeCardClientV2 = new CoffeeCardClientV2(_httpClient); + // CoffeeCardClient = new CoffeeCardClient(_httpClient); + // CoffeeCardClientV2 = new CoffeeCardClientV2(_httpClient); Context = GetCoffeeCardContext(); } @@ -72,6 +74,18 @@ protected void SetDefaultAuthHeader(User user) _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("bearer", token); } + protected void ConfigureMockService(TService service) where TService : class + { + _httpClient = _factory.WithWebHostBuilder(services => + { + services.ConfigureTestServices(services => + { + services.Remove(services.SingleOrDefault(d => d.ServiceType == typeof(TService))); + services.AddSingleton(service); + }); + }).CreateClient(); + } + private string GenerateToken(IEnumerable claims) { var scopedServices = _scope.ServiceProvider; diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs index f5a022b8..ae5b38b7 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs @@ -1,28 +1,23 @@ using System; using System.Collections.Generic; -using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; -using System.Text; using System.Threading.Tasks; using CoffeeCard.Common.Configuration; using CoffeeCard.Common.Errors; using CoffeeCard.Library.Persistence; using CoffeeCard.Library.Services; -using CoffeeCard.Library.Services.v2; -using CoffeeCard.Library.Utils; -using CoffeeCard.Models.DataTransferObjects.v2.Programme; using CoffeeCard.Models.DataTransferObjects.v2.User; using CoffeeCard.Models.Entities; +using CoffeeCard.Tests.Common.Builders; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; using Moq; using Xunit; namespace CoffeeCard.Tests.Unit.Services.v2 { - public class AccountServiceTest + public class AccountServiceTest : BaseUnitTests { private CoffeeCardContext CreateTestCoffeeCardContextWithName(string name) { @@ -45,8 +40,9 @@ private CoffeeCardContext CreateTestCoffeeCardContextWithName(string name) public async Task GetAccountByClaimsReturnsUserClaimWithEmail() { // Arrange - var claims = new List() { new Claim(ClaimTypes.Email, "test@test.test") }; - var expected = new User() { Name = "User", Email = "test@test.test" }; + const string email = "test@test.test"; + var claims = new List() { new Claim(ClaimTypes.Email, email) }; + var expected = UserBuilder.DefaultCustomer().WithEmail(email).Build(); User result; using var context = CreateTestCoffeeCardContextWithName(nameof(GetAccountByClaimsReturnsUserClaimWithEmail)); @@ -72,7 +68,7 @@ public async Task GetAccountByClaimsReturnsUserClaimWithEmail() public async Task GetAccountByClaimsThrowsApiExceptionGivenInvalidClaim(IEnumerable claims) { // Arrange - var validUser = new User() { Name = "User", Email = "test@test.test" }; + var validUser = UserBuilder.DefaultCustomer().Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(GetAccountByClaimsThrowsApiExceptionGivenInvalidClaim) + claims.ToString()); context.Users.Add(validUser); @@ -98,22 +94,15 @@ public async Task GetAccountByClaimsThrowsApiExceptionGivenInvalidClaim(IEnumera public async Task RegisterAccountReturnsUserOnValidInput(String name, String email, string password, int programmeId) { // Arrange - var programme = new Programme() - { - Id = programmeId, - FullName = "test", - ShortName = "t", - Users = new List() - }; + var programme = ProgrammeBuilder.Simple().WithId(programmeId).Build(); var expectedPass = "HashedPassword"; - var expected = new User() - { - Name = name, - Email = email, - Password = expectedPass, - PrivacyActivated = false, - Programme = programme - }; + var expected = UserBuilder.DefaultCustomer() + .WithName(name) + .WithEmail(email) + .WithPassword(expectedPass) + .WithProgramme(programme) + .WithPrivacyActivated(false) + .Build(); User result; // Using same context across all valid users to test creation of multiple users @@ -152,7 +141,7 @@ public async Task RegisterAccountReturnsUserOnValidInput(String name, String ema public async Task RegisterAccountThrowsApiExceptionWithStatus409OnExistingEmail() { // Arrange - var programme = new Programme() { Id = 1, FullName = "test", ShortName = "t", SortPriority = 1, Users = new List() }; + var programme = ProgrammeBuilder.Simple().Build(); var email = "test@test.dk"; using var context = CreateTestCoffeeCardContextWithName(nameof(RegisterAccountThrowsApiExceptionWithStatus409OnExistingEmail)); @@ -205,22 +194,9 @@ public async Task RegisterAccountThrowsApiExceptionWithStatus400WhenGivenInvalid public async Task RegisterAccountSendsVerificationEmailOnlyValidInput() { // Arrange - var programme = new Programme() - { - Id = 1, - FullName = "test", - ShortName = "t", - Users = new List() - }; + var programme = ProgrammeBuilder.Simple().Build(); var expectedPass = "HashedPassword"; - var expected = new User() - { - Name = "name", - Email = "email", - Password = expectedPass, - PrivacyActivated = false, - Programme = programme - }; + var expected = UserBuilder.DefaultCustomer().WithPassword(expectedPass).WithProgramme(programme).Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(RegisterAccountSendsVerificationEmailOnlyValidInput)); var emailServiceMock = new Mock(); @@ -264,13 +240,7 @@ await Assert.ThrowsAsync( public async Task UpdateAccountUpdatesAllNonNullProperties(String name, String email, String? password, bool? privacyActivated, int? programmeId) { // Arrange - var programme = new Programme() - { - Id = 1, - FullName = "test", - ShortName = "t", - Users = new List() - }; + var programme = ProgrammeBuilder.Simple().Build(); var updateUserRequest = new UpdateUserRequest() { Name = name, @@ -279,22 +249,14 @@ public async Task UpdateAccountUpdatesAllNonNullProperties(String name, String e PrivacyActivated = privacyActivated, ProgrammeId = programmeId }; - var user = new User() - { - Name = "name", - Password = "pass", - PrivacyActivated = false, - Email = "test@test.test", - Programme = programme - }; - var expected = new User() - { - Name = name, - Email = email, - Password = password ?? user.Password, - PrivacyActivated = privacyActivated ?? user.PrivacyActivated, - Programme = programme ?? user.Programme - }; + var user = UserBuilder.DefaultCustomer().WithProgramme(programme).Build(); + var expected = UserBuilder.DefaultCustomer() + .WithName(name) + .WithEmail(email) + .WithPassword(password ?? user.Password) + .WithPrivacyActivated(privacyActivated ?? user.PrivacyActivated) + .WithProgramme(programme ?? user.Programme) + .Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(UpdateAccountUpdatesAllNonNullProperties) + name); context.Users.Add(user); @@ -329,13 +291,7 @@ public async Task UpdateAccountUpdatesAllNonNullProperties(String name, String e public async Task UpdateAccountThrowsApiExceptionOnInvalidProgrammeId() { // Arrange - var programme = new Programme() - { - Id = 1, - FullName = "test", - ShortName = "t", - Users = new List() - }; + var programme = ProgrammeBuilder.Simple().WithId(1).Build(); var updateUserRequest = new UpdateUserRequest() { Name = "name", @@ -344,14 +300,7 @@ public async Task UpdateAccountThrowsApiExceptionOnInvalidProgrammeId() PrivacyActivated = false, ProgrammeId = 2 // No prgramme with Id }; - var user = new User() - { - Name = "name", - Password = "pass", - PrivacyActivated = false, - Email = "test@test.test", - Programme = programme - }; + var user = UserBuilder.DefaultCustomer().WithProgramme(programme).Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(UpdateAccountThrowsApiExceptionOnInvalidProgrammeId)); context.Users.Add(user); @@ -377,13 +326,7 @@ public async Task UpdateAccountThrowsApiExceptionOnInvalidProgrammeId() public async Task RequestAnonymizationSendsEmail() { // Arrange - var user = new User() - { - Name = "name", - Password = "pass", - PrivacyActivated = false, - Email = "test@test.test", - }; + var user = UserBuilder.DefaultCustomer().Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(RequestAnonymizationSendsEmail)); context.Users.Add(user); @@ -412,26 +355,14 @@ public async Task AnonymizeAccountRemovesIdentifyableInformationFromUser() { // Arrange var userEmail = "test@test.test"; - var user = new User - { - Id = 1, - Name = "name", - Password = "pass", - Salt = "salt", - UserState = UserState.Active, - PrivacyActivated = false, - Email = userEmail, - }; - var expected = new User - { - Id = 1, - Name = "", - Password = "", - Salt = "", - UserState = UserState.Deleted, - PrivacyActivated = true, - Email = "", - }; + var user = UserBuilder.DefaultCustomer().WithEmail(userEmail).WithUserState(UserState.Active).Build(); + var expected = UserBuilder.DefaultCustomer() + .WithName("") + .WithPassword("") + .WithSalt("") + .WithEmail("") + .WithUserState(UserState.Deleted) + .Build(); await using var context = CreateTestCoffeeCardContextWithName(nameof(AnonymizeAccountRemovesIdentifyableInformationFromUser)); context.Users.Add(user); @@ -467,17 +398,10 @@ public async Task ResendVerificationEmailWhenAccountIsNotVerified() { // Arrange const string userEmail = "test@test.test"; - var user = new User - { - Id = 1, - Name = "name", - Password = "pass", - Salt = "salt", - UserState = UserState.Active, - PrivacyActivated = false, - Email = userEmail, - IsVerified = false - }; + var user = UserBuilder.DefaultCustomer() + .WithEmail(userEmail) + .WithIsVerified(false) + .Build(); await using var context = CreateTestCoffeeCardContextWithName(nameof(ResendVerificationEmailWhenAccountIsNotVerified)); context.Users.Add(user); @@ -507,17 +431,10 @@ public async Task ResendVerificationEmailThrowsConflictExceptionWhenAccountIsAlr { // Arrange const string userEmail = "test@test.test"; - var user = new User - { - Id = 1, - Name = "name", - Password = "pass", - Salt = "salt", - UserState = UserState.Active, - PrivacyActivated = false, - Email = userEmail, - IsVerified = true - }; + var user = UserBuilder.DefaultCustomer() + .WithEmail(userEmail) + .WithIsVerified(true) + .Build(); await using var context = CreateTestCoffeeCardContextWithName(nameof(ResendVerificationEmailThrowsConflictExceptionWhenAccountIsAlreadyVerified)); context.Users.Add(user); @@ -564,13 +481,7 @@ public async Task SendMagicLinkSendsEmailWhenUserIsFound() { // Arrange const string userEmail = "john@cena.com"; - var user = new User - { - Id = 1, - Name = "John Cena", - Password = "pass", - Email = userEmail, - }; + var user = UserBuilder.DefaultCustomer().WithEmail(userEmail).Build(); await using var context = CreateTestCoffeeCardContextWithName(nameof(SendMagicLinkSendsEmailWhenUserIsFound)); @@ -619,15 +530,15 @@ public async Task SendMagicLinkDoesNotSendMailWhenUserIsNotFound() public async Task GenerateTokenPairRevokesTokenOnUse() { // Arrange - var user = new User - { - Id = 1, - Name = "John Cena", - Password = "pass", - Email = "test@test.com", - }; + var user = UserBuilder.DefaultCustomer().Build(); + + const string tokenHash = "refreshToken"; - var refreshToken = new Token("refreshToken", TokenType.Refresh) { User = user }; + var refreshToken = TokenBuilder.Simple() + .WithTokenHash(tokenHash) + .WithType(TokenType.Refresh) + .WithUser(user) + .Build(); await using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateTokenPairRevokesTokenOnUse)); await context.Users.AddAsync(user); @@ -635,7 +546,6 @@ public async Task GenerateTokenPairRevokesTokenOnUse() await context.SaveChangesAsync(); var tokenService = new Mock(); - // tokenService.Setup(t => t.GenerateRefreshTokenAsync(user)).ReturnsAsync("refreshToken"); tokenService.Setup(t => t.GetValidTokenByHashAsync("refreshToken")).ReturnsAsync(refreshToken); var accountService = new Library.Services.v2.AccountService( @@ -647,7 +557,7 @@ public async Task GenerateTokenPairRevokesTokenOnUse() new Mock().Object); // Act - var tokenPair = await accountService.GenerateUserLoginFromToken("refreshToken"); + var tokenPair = await accountService.GenerateUserLoginFromToken(tokenHash); // Assert Assert.True(refreshToken.Revoked); @@ -657,15 +567,15 @@ public async Task GenerateTokenPairRevokesTokenOnUse() public async Task GenerateTokenPairReturnsTokenPair() { // Arrange - var user = new User - { - Id = 1, - Name = "John Cena", - Password = "pass", - Email = "test@test.com", - }; + var user = UserBuilder.DefaultCustomer().Build(); + + const string tokenHash = "refreshToken"; - var refreshToken = new Token("refreshToken", TokenType.Refresh) { User = user }; + var refreshToken = TokenBuilder.Simple() + .WithTokenHash(tokenHash) + .WithType(TokenType.Refresh) + .WithUser(user) + .Build(); await using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateTokenPairReturnsTokenPair)); await context.Users.AddAsync(user); @@ -674,7 +584,7 @@ public async Task GenerateTokenPairReturnsTokenPair() var tokenServicev2 = new Mock(); tokenServicev2.Setup(t => t.GenerateRefreshTokenAsync(user)).ReturnsAsync("newToken"); - tokenServicev2.Setup(t => t.GetValidTokenByHashAsync("refreshToken")).ReturnsAsync(refreshToken); + tokenServicev2.Setup(t => t.GetValidTokenByHashAsync(tokenHash)).ReturnsAsync(refreshToken); var tokenServicev1 = new Mock(); tokenServicev1.Setup(t => t.GenerateToken(It.IsAny>())).Returns("jwtToken"); @@ -688,7 +598,7 @@ public async Task GenerateTokenPairReturnsTokenPair() new Mock().Object); // Act - var tokenPair = await accountService.GenerateUserLoginFromToken("refreshToken"); + var tokenPair = await accountService.GenerateUserLoginFromToken(tokenHash); // Assert Assert.NotNull(tokenPair); diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs index 9740c175..328f5528 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs @@ -6,6 +6,7 @@ using CoffeeCard.Library.Persistence; using CoffeeCard.Library.Services; using CoffeeCard.Models.Entities; +using CoffeeCard.Tests.Common.Builders; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; @@ -13,7 +14,7 @@ namespace CoffeeCard.Tests.Unit.Services.v2 { - public class TokenServiceTests + public class TokenServiceTests : BaseUnitTests { private CoffeeCardContext CreateTestCoffeeCardContextWithName(string name) { @@ -36,12 +37,7 @@ private CoffeeCardContext CreateTestCoffeeCardContextWithName(string name) public async Task GenerateMagicLink_ReturnsLinkWithValidTokenForUser() { // Arrange - var user = new User - { - Id = 1, - Email = "test@test.com", - Name = "Test User" - }; + var user = UserBuilder.DefaultCustomer().Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateMagicLink_ReturnsLinkWithValidTokenForUser)); @@ -66,12 +62,7 @@ public async Task GenerateMagicLink_ReturnsLinkWithValidTokenForUser() public async Task GenerateRefreshTokenAsync_ReturnsValidTokenForUser() { // Arrange - var user = new User - { - Id = 1, - Email = "test@test.com", - Name = "Test User" - }; + var user = UserBuilder.DefaultCustomer().Build(); using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateRefreshTokenAsync_ReturnsValidTokenForUser)); @@ -109,21 +100,12 @@ public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenDoesNotExist() public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenIsRevoked() { // Arrange - var user = new User - { - Id = 1, - Email = "test@test.com", - Name = "Test User" - }; - using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_ThrowsExceptionIfTokenIsRevoked)); - context.Users.Add(user); + var token = TokenBuilder.Simple().Build(); - var token = new Token("token", TokenType.Refresh) { User = user }; context.Tokens.Add(token); - token.Revoked = true; await context.SaveChangesAsync(); var tokenService = new TokenService(context, Mock.Of()); @@ -136,18 +118,9 @@ public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenIsRevoked() public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenHasExpired() { // Arrange - var user = new User - { - Id = 1, - Email = "test@test.com", - Name = "Test User" - }; - using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_ThrowsExceptionIfTokenHasExpired)); - context.Users.Add(user); - - var token = new Token("token", TokenType.Refresh) { User = user, Expires = DateTime.Now.AddDays(-1) }; + var token = TokenBuilder.Simple().WithExpires(DateTime.Now.AddDays(-1)).Build(); context.Tokens.Add(token); await context.SaveChangesAsync(); @@ -162,29 +135,20 @@ public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenHasExpired() public async Task GetValidTokenByHashAsync_ReturnsTokenByValidHash() { // Arrange - var user = new User - { - Id = 1, - Email = "test@test.com", - Name = "Test User" - }; - using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_ReturnsTokenByValidHash)); - context.Users.Add(user); - - var token = new Token("token", TokenType.Refresh) { User = user }; + var token = TokenBuilder.Simple().Build(); context.Tokens.Add(token); await context.SaveChangesAsync(); var hashService = new Mock(); - hashService.Setup(h => h.Hash(It.IsAny())).Returns("token"); + hashService.Setup(h => h.Hash(It.IsAny())).Returns(token.TokenHash); var tokenService = new TokenService(context, hashService.Object); // Act & Assert - var result = await tokenService.GetValidTokenByHashAsync("token"); + var result = await tokenService.GetValidTokenByHashAsync(token.TokenHash); // Assert Assert.NotNull(result); @@ -197,19 +161,13 @@ public async Task GetValidTokenByHashAsync_ReturnsTokenByValidHash() public async Task GetValidTokenByHashAsync_InvalidatesUsersRefreshTokensIfTokenIsInvalid() { // Arrange - var user = new User - { - Id = 1, - Email = "test@test.com", - Name = "Test User" - }; - using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_InvalidatesUsersRefreshTokensIfTokenIsInvalid)); + var user = UserBuilder.DefaultCustomer().Build(); context.Users.Add(user); - var token = new Token("token", TokenType.Refresh) { User = user, Revoked = true }; - var refreshToken = new Token("refresh", TokenType.Refresh) { User = user }; + var token = TokenBuilder.Simple().WithUser(user).WithRevoked(true).WithType(TokenType.Refresh).Build(); + var refreshToken = TokenBuilder.Simple().WithUser(user).WithType(TokenType.Refresh).Build(); Token[] otherTokens = { @@ -223,12 +181,12 @@ public async Task GetValidTokenByHashAsync_InvalidatesUsersRefreshTokensIfTokenI await context.SaveChangesAsync(); var hashService = new Mock(); - hashService.Setup(h => h.Hash(It.IsAny())).Returns("token"); + hashService.Setup(h => h.Hash(It.IsAny())).Returns(token.TokenHash); var tokenService = new TokenService(context, hashService.Object); // Act & Assert - await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync("token")); + await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync(token.TokenHash)); // Assert Assert.True(refreshToken.Revoked); From 142119c914816cdcf2712e8c9e219b11eb1022b5 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 3 Dec 2024 19:54:53 +0100 Subject: [PATCH 28/37] Use correct logger for email service --- .../Services/v2/EmailService.cs | 8 +++++--- .../WebApplication/BaseIntegrationTest.cs | 16 ++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs index 0489f471..8583b615 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs @@ -4,8 +4,8 @@ using CoffeeCard.Common.Configuration; using CoffeeCard.Models.Entities; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; using MimeKit; -using Serilog; namespace CoffeeCard.Library.Services.v2 { @@ -14,18 +14,20 @@ public class EmailService : IEmailService private readonly IWebHostEnvironment _env; private readonly EnvironmentSettings _environmentSettings; private readonly IEmailSender _emailSender; + private readonly ILogger _logger; public EmailService(IEmailSender emailSender, EnvironmentSettings environmentSettings, - IWebHostEnvironment env) + IWebHostEnvironment env, ILogger logger) { _emailSender = emailSender; _environmentSettings = environmentSettings; _env = env; + _logger = logger; } public async Task SendMagicLink(User user, string magicLink, LoginType loginType) { - Log.Information("Sending magic link email to {email} {userid}", user.Email, user.Id); + _logger.LogInformation("Sending magic link email to {email} {userid}", user.Email, user.Id); var message = new MimeMessage(); var builder = RetrieveTemplate("email_magic_link_login.html"); var baseUrl = loginType switch diff --git a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs index cdc28217..96c6394a 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs +++ b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs @@ -21,13 +21,13 @@ namespace CoffeeCard.Tests.Integration.WebApplication { [Collection("Integration tests, to be run sequentially")] - public abstract class BaseIntegrationTest : CustomWebApplicationFactory, IClassFixture> + public abstract class BaseIntegrationTest : IClassFixture> { private readonly CustomWebApplicationFactory _factory; private readonly IServiceScope _scope; private HttpClient _httpClient; - protected CoffeeCardClient CoffeeCardClient => new(_httpClient); - protected CoffeeCardClientV2 CoffeeCardClientV2 => new(_httpClient); + protected CoffeeCardClient CoffeeCardClient; + protected CoffeeCardClientV2 CoffeeCardClientV2; protected readonly CoffeeCardContext Context; protected BaseIntegrationTest(CustomWebApplicationFactory factory) @@ -40,14 +40,14 @@ protected BaseIntegrationTest(CustomWebApplicationFactory factory) _scope = _factory.Services.CreateScope(); _httpClient = GetHttpClient(); - // CoffeeCardClient = new CoffeeCardClient(_httpClient); - // CoffeeCardClientV2 = new CoffeeCardClientV2(_httpClient); + CoffeeCardClient = new CoffeeCardClient(_httpClient); + CoffeeCardClientV2 = new CoffeeCardClientV2(_httpClient); Context = GetCoffeeCardContext(); } private HttpClient GetHttpClient() { - var client = CreateClient(); + var client = _factory.CreateClient(); return client; } @@ -122,12 +122,12 @@ private CoffeeCardContext GetCoffeeCardContext() return context; } - public override ValueTask DisposeAsync() + public ValueTask DisposeAsync() { _scope.Dispose(); _httpClient.Dispose(); GC.SuppressFinalize(this); - return base.DisposeAsync(); + return new ValueTask(Task.CompletedTask); } } } \ No newline at end of file From 019b4b056dbae1e870b333140767f73a9f97f287 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 3 Dec 2024 20:08:53 +0100 Subject: [PATCH 29/37] Allow mocking services in integration tests --- .../WebApplication/BaseIntegrationTest.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs index 96c6394a..1ed55b9d 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs +++ b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs @@ -26,8 +26,8 @@ public abstract class BaseIntegrationTest : IClassFixture _factory; private readonly IServiceScope _scope; private HttpClient _httpClient; - protected CoffeeCardClient CoffeeCardClient; - protected CoffeeCardClientV2 CoffeeCardClientV2; + protected CoffeeCardClient CoffeeCardClient => new(_httpClient); + protected CoffeeCardClientV2 CoffeeCardClientV2 => new(_httpClient); protected readonly CoffeeCardContext Context; protected BaseIntegrationTest(CustomWebApplicationFactory factory) @@ -40,8 +40,6 @@ protected BaseIntegrationTest(CustomWebApplicationFactory factory) _scope = _factory.Services.CreateScope(); _httpClient = GetHttpClient(); - CoffeeCardClient = new CoffeeCardClient(_httpClient); - CoffeeCardClientV2 = new CoffeeCardClientV2(_httpClient); Context = GetCoffeeCardContext(); } From 8fb74321092dab2f7fd8d92bad275f768f886b9e Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 3 Dec 2024 20:10:20 +0100 Subject: [PATCH 30/37] Fix email service logger --- coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs index 8583b615..224906cc 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/EmailService.cs @@ -14,10 +14,10 @@ public class EmailService : IEmailService private readonly IWebHostEnvironment _env; private readonly EnvironmentSettings _environmentSettings; private readonly IEmailSender _emailSender; - private readonly ILogger _logger; + private readonly ILogger _logger; public EmailService(IEmailSender emailSender, EnvironmentSettings environmentSettings, - IWebHostEnvironment env, ILogger logger) + IWebHostEnvironment env, ILogger logger) { _emailSender = emailSender; _environmentSettings = environmentSettings; From 8c6be607d9f45dea7ec7f08f79036c3921beabad Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Tue, 3 Dec 2024 20:54:34 +0100 Subject: [PATCH 31/37] TokenServiceTests with initial and assertion contexts --- .../Services/v2/TokenService.cs | 2 +- .../WebApplication/BaseIntegrationTest.cs | 3 +- .../Services/v2/TokenServiceTests.cs | 79 ++++++------------- 3 files changed, 26 insertions(+), 58 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index f8603083..af1f8eb6 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -33,7 +33,7 @@ public async Task GenerateRefreshTokenAsync(User user) { var refreshToken = Guid.NewGuid().ToString(); var hashedToken = _hashService.Hash(refreshToken); - _context.Tokens.Add(new Token(hashedToken, TokenType.Refresh) { User = user }); + _context.Tokens.Add(new Token(hashedToken, TokenType.Refresh) { UserId = user.Id }); await _context.SaveChangesAsync(); return refreshToken; } diff --git a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs index 1ed55b9d..a8c8cb4b 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs +++ b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs @@ -21,7 +21,7 @@ namespace CoffeeCard.Tests.Integration.WebApplication { [Collection("Integration tests, to be run sequentially")] - public abstract class BaseIntegrationTest : IClassFixture> + public abstract class BaseIntegrationTest : IClassFixture>, IAsyncDisposable { private readonly CustomWebApplicationFactory _factory; private readonly IServiceScope _scope; @@ -123,7 +123,6 @@ private CoffeeCardContext GetCoffeeCardContext() public ValueTask DisposeAsync() { _scope.Dispose(); - _httpClient.Dispose(); GC.SuppressFinalize(this); return new ValueTask(Task.CompletedTask); } diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs index 328f5528..1219d30b 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs @@ -16,35 +16,16 @@ namespace CoffeeCard.Tests.Unit.Services.v2 { public class TokenServiceTests : BaseUnitTests { - private CoffeeCardContext CreateTestCoffeeCardContextWithName(string name) - { - var builder = new DbContextOptionsBuilder() - .UseInMemoryDatabase(name); - - var databaseSettings = new DatabaseSettings - { - SchemaName = "test" - }; - var environmentSettings = new EnvironmentSettings() - { - EnvironmentType = EnvironmentType.Test - }; - - return new CoffeeCardContext(builder.Options, databaseSettings, environmentSettings); - } - [Fact(DisplayName = "GenerateMagicLink returns a link with a valid token for user")] public async Task GenerateMagicLink_ReturnsLinkWithValidTokenForUser() { // Arrange var user = UserBuilder.DefaultCustomer().Build(); - using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateMagicLink_ReturnsLinkWithValidTokenForUser)); - - context.Users.Add(user); - await context.SaveChangesAsync(); + InitialContext.Users.Add(user); + await InitialContext.SaveChangesAsync(); - var tokenService = new TokenService(context, Mock.Of()); + var tokenService = new TokenService(AssertionContext, Mock.Of()); // Act var result = await tokenService.GenerateMagicLink(user); @@ -64,13 +45,11 @@ public async Task GenerateRefreshTokenAsync_ReturnsValidTokenForUser() // Arrange var user = UserBuilder.DefaultCustomer().Build(); - using var context = CreateTestCoffeeCardContextWithName(nameof(GenerateRefreshTokenAsync_ReturnsValidTokenForUser)); - - context.Users.Add(user); - await context.SaveChangesAsync(); + InitialContext.Users.Add(user); + await InitialContext.SaveChangesAsync(); var hashService = new HashService(); - var tokenService = new TokenService(context, hashService); + var tokenService = new TokenService(AssertionContext, hashService); // Act var result = await tokenService.GenerateRefreshTokenAsync(user); @@ -79,7 +58,7 @@ public async Task GenerateRefreshTokenAsync_ReturnsValidTokenForUser() Assert.NotNull(result); Assert.NotEmpty(result); // We cannot assert on tokenHash since it has been hashed for security reasons. Hashing is tested elsewhere. - var token = user.Tokens.First(); + var token = await AssertionContext.Tokens.FirstOrDefaultAsync(); Assert.Equal(TokenType.Refresh, token.Type); Assert.False(token.Revoked, "Token should not be revoked"); } @@ -88,9 +67,7 @@ public async Task GenerateRefreshTokenAsync_ReturnsValidTokenForUser() public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenDoesNotExist() { // Arrange - using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_ThrowsExceptionIfTokenDoesNotExist)); - - var tokenService = new TokenService(context, Mock.Of()); + var tokenService = new TokenService(AssertionContext, Mock.Of()); // Act & Assert await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync("token")); @@ -100,15 +77,13 @@ public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenDoesNotExist() public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenIsRevoked() { // Arrange - using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_ThrowsExceptionIfTokenIsRevoked)); - var token = TokenBuilder.Simple().Build(); - context.Tokens.Add(token); + InitialContext.Tokens.Add(token); - await context.SaveChangesAsync(); + await InitialContext.SaveChangesAsync(); - var tokenService = new TokenService(context, Mock.Of()); + var tokenService = new TokenService(AssertionContext, Mock.Of()); // Act & Assert await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync("token")); @@ -118,14 +93,12 @@ public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenIsRevoked() public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenHasExpired() { // Arrange - using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_ThrowsExceptionIfTokenHasExpired)); - var token = TokenBuilder.Simple().WithExpires(DateTime.Now.AddDays(-1)).Build(); - context.Tokens.Add(token); + InitialContext.Tokens.Add(token); - await context.SaveChangesAsync(); + await InitialContext.SaveChangesAsync(); - var tokenService = new TokenService(context, Mock.Of()); + var tokenService = new TokenService(AssertionContext, Mock.Of()); // Act & Assert await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync("token")); @@ -135,17 +108,15 @@ public async Task GetValidTokenByHashAsync_ThrowsExceptionIfTokenHasExpired() public async Task GetValidTokenByHashAsync_ReturnsTokenByValidHash() { // Arrange - using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_ReturnsTokenByValidHash)); - var token = TokenBuilder.Simple().Build(); - context.Tokens.Add(token); + InitialContext.Tokens.Add(token); - await context.SaveChangesAsync(); + await InitialContext.SaveChangesAsync(); var hashService = new Mock(); hashService.Setup(h => h.Hash(It.IsAny())).Returns(token.TokenHash); - var tokenService = new TokenService(context, hashService.Object); + var tokenService = new TokenService(AssertionContext, hashService.Object); // Act & Assert var result = await tokenService.GetValidTokenByHashAsync(token.TokenHash); @@ -161,10 +132,8 @@ public async Task GetValidTokenByHashAsync_ReturnsTokenByValidHash() public async Task GetValidTokenByHashAsync_InvalidatesUsersRefreshTokensIfTokenIsInvalid() { // Arrange - using var context = CreateTestCoffeeCardContextWithName(nameof(GetValidTokenByHashAsync_InvalidatesUsersRefreshTokensIfTokenIsInvalid)); - var user = UserBuilder.DefaultCustomer().Build(); - context.Users.Add(user); + InitialContext.Users.Add(user); var token = TokenBuilder.Simple().WithUser(user).WithRevoked(true).WithType(TokenType.Refresh).Build(); var refreshToken = TokenBuilder.Simple().WithUser(user).WithType(TokenType.Refresh).Build(); @@ -175,24 +144,24 @@ public async Task GetValidTokenByHashAsync_InvalidatesUsersRefreshTokensIfTokenI new ("reset", TokenType.ResetPassword) {User = user}, }; - context.Tokens.AddRange(token, refreshToken); - context.Tokens.AddRange(otherTokens); + InitialContext.Tokens.AddRange(token, refreshToken); + InitialContext.Tokens.AddRange(otherTokens); - await context.SaveChangesAsync(); + await InitialContext.SaveChangesAsync(); var hashService = new Mock(); hashService.Setup(h => h.Hash(It.IsAny())).Returns(token.TokenHash); - var tokenService = new TokenService(context, hashService.Object); + var tokenService = new TokenService(AssertionContext, hashService.Object); // Act & Assert await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync(token.TokenHash)); // Assert - Assert.True(refreshToken.Revoked); + Assert.True((await AssertionContext.Tokens.FirstOrDefaultAsync(t => t.Id == refreshToken.Id)).Revoked); foreach (var otherToken in otherTokens) { - Assert.False(otherToken.Revoked); + Assert.False((await AssertionContext.Tokens.FirstOrDefaultAsync(t => t.Id == otherToken.Id)).Revoked); } } } From b051b7c608b57e03e8c8684392b2dbbdcda3a950 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Sat, 28 Dec 2024 19:03:11 +0100 Subject: [PATCH 32/37] Renamed to MagicLinkToken --- coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs | 2 +- coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs | 2 +- coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs | 2 +- .../CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index c31288bd..10e1d688 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -314,7 +314,7 @@ public async Task SendMagicLinkEmail(string email, LoginType loginType) // Should not throw error to prevent showing a malicious user if an email is already registered return; } - var magicLinkTokenHash = await _tokenServiceV2.GenerateMagicLink(user); + var magicLinkTokenHash = await _tokenServiceV2.GenerateMagicLinkToken(user); await _emailServiceV2.SendMagicLink(user, magicLinkTokenHash, loginType); } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs index db759d9a..3df58ac4 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/ITokenService.cs @@ -5,7 +5,7 @@ namespace CoffeeCard.Library.Services.v2 { public interface ITokenService { - Task GenerateMagicLink(User user); + Task GenerateMagicLinkToken(User user); Task GenerateRefreshTokenAsync(User user); Task GetValidTokenByHashAsync(string tokenString); } diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index af1f8eb6..923a9f7c 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -19,7 +19,7 @@ public TokenService(CoffeeCardContext context, IHashService hashService) _hashService = hashService; } - public async Task GenerateMagicLink(User user) + public async Task GenerateMagicLinkToken(User user) { var guid = Guid.NewGuid().ToString(); var magicLinkToken = new Token(guid, TokenType.MagicLink); diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs index 1219d30b..0e7b10f1 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs @@ -28,7 +28,7 @@ public async Task GenerateMagicLink_ReturnsLinkWithValidTokenForUser() var tokenService = new TokenService(AssertionContext, Mock.Of()); // Act - var result = await tokenService.GenerateMagicLink(user); + var result = await tokenService.GenerateMagicLinkToken(user); // Assert Assert.NotNull(result); From 595bf293b844bea5ef53db23a820d3b52897efb5 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Sat, 28 Dec 2024 21:07:55 +0100 Subject: [PATCH 33/37] DB-side token validation --- ...184557_AddTokenExpiredFunction.Designer.cs | 668 ++++++++++++++++++ .../20241228184557_AddTokenExpiredFunction.cs | 29 + .../Persistence/CoffeecardContext.cs | 4 + .../Services/v2/TokenService.cs | 26 +- .../CoffeeCard.Models/Entities/Token.cs | 5 +- .../Services/v2/TokenServiceTests.cs | 2 +- 6 files changed, 712 insertions(+), 22 deletions(-) create mode 100644 coffeecard/CoffeeCard.Library/Migrations/20241228184557_AddTokenExpiredFunction.Designer.cs create mode 100644 coffeecard/CoffeeCard.Library/Migrations/20241228184557_AddTokenExpiredFunction.cs diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241228184557_AddTokenExpiredFunction.Designer.cs b/coffeecard/CoffeeCard.Library/Migrations/20241228184557_AddTokenExpiredFunction.Designer.cs new file mode 100644 index 00000000..720bd48d --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Migrations/20241228184557_AddTokenExpiredFunction.Designer.cs @@ -0,0 +1,668 @@ +// +using System; +using CoffeeCard.Library.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CoffeeCard.Library.Migrations +{ + [DbContext(typeof(CoffeeCardContext))] + [Migration("20241228184557_AddTokenExpiredFunction")] + partial class AddTokenExpiredFunction + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("dbo") + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CoffeeCard.Models.Entities.LoginAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Time") + .HasColumnType("datetime2"); + + b.Property("User_Id") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("User_Id"); + + b.ToTable("LoginAttempts", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItemProduct", b => + { + b.Property("MenuItemId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.HasKey("MenuItemId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("MenuItemProducts", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.PosPurhase", b => + { + b.Property("PurchaseId") + .HasColumnType("int"); + + b.Property("BaristaInitials") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("PurchaseId"); + + b.ToTable("PosPurchases", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExperienceWorth") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NumberOfTickets") + .HasColumnType("int"); + + b.Property("Price") + .HasColumnType("int"); + + b.Property("Visible") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Products", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.ProductUserGroup", b => + { + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("UserGroup") + .HasColumnType("int"); + + b.HasKey("ProductId", "UserGroup"); + + b.ToTable("ProductUserGroups", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Programme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FullName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ShortName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SortPriority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Programmes", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("ExternalTransactionId") + .HasColumnType("nvarchar(450)"); + + b.Property("NumberOfTickets") + .HasColumnType("int"); + + b.Property("OrderId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Price") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PurchasedById") + .HasColumnType("int") + .HasColumnName("PurchasedBy_Id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalTransactionId"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("PurchasedById"); + + b.ToTable("Purchases", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Statistic", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExpiryDate") + .HasColumnType("datetime2"); + + b.Property("LastSwipe") + .HasColumnType("datetime2"); + + b.Property("Preset") + .HasColumnType("int"); + + b.Property("SwipeCount") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Preset", "ExpiryDate"); + + b.ToTable("Statistics", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUsed") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("OwnerId") + .HasColumnType("int") + .HasColumnName("Owner_Id"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("PurchaseId") + .HasColumnType("int") + .HasColumnName("Purchase_Id"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UsedOnMenuItemId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("UsedOnMenuItemId"); + + b.ToTable("Tickets", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Token", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Expires") + .HasColumnType("datetime2"); + + b.Property("Revoked") + .HasColumnType("bit"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("Tokens", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUpdated") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Experience") + .HasColumnType("int"); + + b.Property("IsVerified") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Password") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacyActivated") + .HasColumnType("bit"); + + b.Property("ProgrammeId") + .HasColumnType("int") + .HasColumnName("Programme_Id"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserGroup") + .HasColumnType("int"); + + b.Property("UserState") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("Name"); + + b.HasIndex("ProgrammeId"); + + b.HasIndex("UserGroup"); + + b.ToTable("Users", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Voucher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("DateCreated") + .HasColumnType("datetime2"); + + b.Property("DateUsed") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ProductId") + .HasColumnType("int") + .HasColumnName("Product_Id"); + + b.Property("PurchaseId") + .HasColumnType("int"); + + b.Property("Requester") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("User_Id"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("UserId"); + + b.ToTable("Vouchers", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.WebhookConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("LastUpdated") + .HasColumnType("datetime2"); + + b.Property("SignatureKey") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WebhookConfigurations", "dbo"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.LoginAttempt", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("LoginAttempts") + .HasForeignKey("User_Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItemProduct", b => + { + b.HasOne("CoffeeCard.Models.Entities.MenuItem", "MenuItem") + .WithMany("MenuItemProducts") + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany("MenuItemProducts") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MenuItem"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.PosPurhase", b => + { + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Purchase"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.ProductUserGroup", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany("ProductUserGroup") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.User", "PurchasedBy") + .WithMany("Purchases") + .HasForeignKey("PurchasedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("PurchasedBy"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Statistic", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("Statistics") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Ticket", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "Owner") + .WithMany("Tickets") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany("Tickets") + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.MenuItem", "UsedOnMenuItem") + .WithMany() + .HasForeignKey("UsedOnMenuItemId"); + + b.Navigation("Owner"); + + b.Navigation("Purchase"); + + b.Navigation("UsedOnMenuItem"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Token", b => + { + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.HasOne("CoffeeCard.Models.Entities.Programme", "Programme") + .WithMany("Users") + .HasForeignKey("ProgrammeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Programme"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Voucher", b => + { + b.HasOne("CoffeeCard.Models.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CoffeeCard.Models.Entities.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId"); + + b.HasOne("CoffeeCard.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Product"); + + b.Navigation("Purchase"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.MenuItem", b => + { + b.Navigation("MenuItemProducts"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Product", b => + { + b.Navigation("MenuItemProducts"); + + b.Navigation("ProductUserGroup"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Programme", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.Purchase", b => + { + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("CoffeeCard.Models.Entities.User", b => + { + b.Navigation("LoginAttempts"); + + b.Navigation("Purchases"); + + b.Navigation("Statistics"); + + b.Navigation("Tickets"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/coffeecard/CoffeeCard.Library/Migrations/20241228184557_AddTokenExpiredFunction.cs b/coffeecard/CoffeeCard.Library/Migrations/20241228184557_AddTokenExpiredFunction.cs new file mode 100644 index 00000000..9c3047f6 --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Migrations/20241228184557_AddTokenExpiredFunction.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CoffeeCard.Library.Migrations +{ + /// + public partial class AddTokenExpiredFunction : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" + CREATE OR ALTER FUNCTION dbo.Expired(@Expires DATETIME) + RETURNS BIT + AS + BEGIN + RETURN CASE WHEN GETUTCDATE() > @EXPIRES THEN 1 ELSE 0 END + END + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DROP FUNCTION IF EXISTS dbo.Expires"); + } + } +} diff --git a/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs b/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs index b41c10a2..3336b974 100644 --- a/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs +++ b/coffeecard/CoffeeCard.Library/Persistence/CoffeecardContext.cs @@ -104,6 +104,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .Property(t => t.TokenHash).IsRequired(); + + modelBuilder.HasDbFunction(typeof(Token).GetMethod(nameof(Token.Expired))) + .HasSchema("dbo") + .HasName("Expired"); } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index 923a9f7c..d266eb0d 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -22,11 +22,11 @@ public TokenService(CoffeeCardContext context, IHashService hashService) public async Task GenerateMagicLinkToken(User user) { var guid = Guid.NewGuid().ToString(); - var magicLinkToken = new Token(guid, TokenType.MagicLink); + var magicLinkToken = new Token(_hashService.Hash(guid), TokenType.MagicLink); user.Tokens.Add(magicLinkToken); await _context.SaveChangesAsync(); - return magicLinkToken.TokenHash; + return guid; } public async Task GenerateRefreshTokenAsync(User user) @@ -41,26 +41,14 @@ public async Task GenerateRefreshTokenAsync(User user) public async Task GetValidTokenByHashAsync(string tokenString) { var tokenHash = _hashService.Hash(tokenString); - var foundToken = await _context.Tokens.Include(t => t.User).FirstOrDefaultAsync(t => t.TokenHash == tokenHash); - if (foundToken == null || foundToken.Revoked || foundToken.Expired()) + var foundToken = await _context.Tokens + .Include(t => t.User) + .FirstOrDefaultAsync(t => t.TokenHash == tokenHash && !t.Revoked && !Token.Expired(t.Expires)); + + if (foundToken == null) { - await InvalidateRefreshTokensForUser(foundToken?.User); throw new ApiException("Invalid token", 401); } return foundToken; } - - private async Task InvalidateRefreshTokensForUser(User user) - { - if (user is null) return; - - var tokens = _context.Tokens.Where(t => t.UserId == user.Id && t.Type == TokenType.Refresh); - - _context.Tokens.UpdateRange(tokens); - foreach (var token in tokens) - { - token.Revoked = true; - } - await _context.SaveChangesAsync(); - } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Models/Entities/Token.cs b/coffeecard/CoffeeCard.Models/Entities/Token.cs index 06134ec4..6e20ce83 100644 --- a/coffeecard/CoffeeCard.Models/Entities/Token.cs +++ b/coffeecard/CoffeeCard.Models/Entities/Token.cs @@ -71,9 +71,10 @@ public override int GetHashCode() /// Determines if the token has expired /// /// bool - public bool Expired() + [DbFunction("Expired", "dbo")] + public static bool Expired(DateTime expires) { - return DateTime.UtcNow > Expires; + return DateTime.UtcNow > expires; } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs index 0e7b10f1..0d2bd3b4 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs @@ -125,7 +125,7 @@ public async Task GetValidTokenByHashAsync_ReturnsTokenByValidHash() Assert.NotNull(result); Assert.Equal(token, result); Assert.False(result.Revoked); - Assert.False(result.Expired()); + Assert.False(Token.Expired(result.Expires)); } [Fact(DisplayName = "GetValidTokenByHashAsync invalidates users refresh tokens if token is invalid")] From d5c5e1203d6fa43717b2509cff10cbeff747be13 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Sat, 28 Dec 2024 21:27:35 +0100 Subject: [PATCH 34/37] Wrap magic link token in DTO --- .../Services/v2/AccountService.cs | 5 ++-- .../Services/v2/IAccountService.cs | 3 ++- .../v2/Token/TokenLoginRequest.cs | 23 +++++++++++++++++++ .../Controllers/v2/AccountController.cs | 9 ++++---- 4 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Token/TokenLoginRequest.cs diff --git a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs index 10e1d688..7b18a907 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/AccountService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using CoffeeCard.Common.Errors; using CoffeeCard.Library.Persistence; +using CoffeeCard.Models.DataTransferObjects.v2.Token; using CoffeeCard.Models.DataTransferObjects.v2.User; using CoffeeCard.Models.Entities; using Microsoft.AspNetCore.Http; @@ -318,10 +319,10 @@ public async Task SendMagicLinkEmail(string email, LoginType loginType) await _emailServiceV2.SendMagicLink(user, magicLinkTokenHash, loginType); } - public async Task GenerateUserLoginFromToken(string token) + public async Task GenerateUserLoginFromToken(TokenLoginRequest loginRequest) { // Validate token in DB - var foundToken = await _tokenServiceV2.GetValidTokenByHashAsync(token); + var foundToken = await _tokenServiceV2.GetValidTokenByHashAsync(loginRequest.Token); // Invalidate token in DB foundToken.Revoked = true; diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs index f768de13..10811f73 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/IAccountService.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using System.Threading.Tasks; using CoffeeCard.Common.Errors; +using CoffeeCard.Models.DataTransferObjects.v2.Token; using CoffeeCard.Models.DataTransferObjects.v2.User; using CoffeeCard.Models.Entities; using Microsoft.AspNetCore.Mvc; @@ -78,7 +79,7 @@ public interface IAccountService /// Task UpdatePriviligedUserGroups(WebhookUpdateUserGroupRequest request); - Task GenerateUserLoginFromToken(string token); + Task GenerateUserLoginFromToken(TokenLoginRequest loginRequest); Task SendMagicLinkEmail(string email, LoginType loginType); } diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Token/TokenLoginRequest.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Token/TokenLoginRequest.cs new file mode 100644 index 00000000..5944b5aa --- /dev/null +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Token/TokenLoginRequest.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace CoffeeCard.Models.DataTransferObjects.v2.Token +{ + /// + /// Magic link request object + /// + /// + /// { + /// "token": "[no example provided]", + /// } + /// + public class TokenLoginRequest + { + /// + /// Magic link token + /// + /// Token + /// [no example provided] + [Required] + public string Token { get; set; } = null!; + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index a5a62d7f..58b3ac3a 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -15,6 +15,7 @@ using CoffeeCard.WebApi.Helpers; using System.ComponentModel.DataAnnotations; using CoffeeCard.Models.DataTransferObjects.User; +using CoffeeCard.Models.DataTransferObjects.v2.Token; using Microsoft.AspNetCore.Identity.Data; namespace CoffeeCard.WebApi.Controllers.v2 @@ -258,13 +259,13 @@ public async Task Login([FromBody] UserLoginRequest request) [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(MessageResponseDto), StatusCodes.Status404NotFound)] [ProducesDefaultResponseType] - public async Task> Authenticate(string tokenHash) + public async Task> Authenticate(TokenLoginRequest token) { - if (tokenHash is null) + if (token is null) return NotFound(new MessageResponseDto { Message = "Token required for app authentication." }); - var token = await _accountService.GenerateUserLoginFromToken(tokenHash); - return Ok(token); + var userTokens = await _accountService.GenerateUserLoginFromToken(token); + return Ok(userTokens); } } } From 3d088b85b6a236ed215e3d8211c9791242c2175e Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Sat, 28 Dec 2024 22:34:39 +0100 Subject: [PATCH 35/37] Fix tests --- .../Services/v2/TokenService.cs | 4 +- .../Controllers/v2/LoginTest.cs | 2 +- .../Services/v2/AccountServiceTest.cs | 5 +- .../Services/v2/TokenServiceTests.cs | 46 +++---------------- 4 files changed, 12 insertions(+), 45 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index d266eb0d..e18a0848 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -22,9 +22,9 @@ public TokenService(CoffeeCardContext context, IHashService hashService) public async Task GenerateMagicLinkToken(User user) { var guid = Guid.NewGuid().ToString(); - var magicLinkToken = new Token(_hashService.Hash(guid), TokenType.MagicLink); + var magicLinkToken = new Token(_hashService.Hash(guid), TokenType.MagicLink){User = user}; + _context.Tokens.Add(magicLinkToken); - user.Tokens.Add(magicLinkToken); await _context.SaveChangesAsync(); return guid; } diff --git a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs index 5f233d54..61f12e0d 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs +++ b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs @@ -76,7 +76,7 @@ public async Task Known_user_login_succeeds_returns_token() await Context.SaveChangesAsync(); // We authenticate using the non-hashed token and let the backend hash the string for us - var response = await CoffeeCardClientV2.Account_AuthenticateAsync(tokenString); + var response = await CoffeeCardClientV2.Account_AuthenticateAsync(new TokenLoginRequest(){Token = tokenString}); Assert.NotNull(response.Jwt); Assert.NotNull(response.RefreshToken); diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs index 8504bafd..7de81fe9 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs @@ -7,6 +7,7 @@ using CoffeeCard.Common.Errors; using CoffeeCard.Library.Persistence; using CoffeeCard.Library.Services; +using CoffeeCard.Models.DataTransferObjects.v2.Token; using CoffeeCard.Models.DataTransferObjects.v2.User; using CoffeeCard.Models.Entities; using CoffeeCard.Tests.Common.Builders; @@ -577,7 +578,7 @@ public async Task GenerateTokenPairRevokesTokenOnUse() NullLogger.Instance); // Act - var tokenPair = await accountService.GenerateUserLoginFromToken(tokenHash); + var tokenPair = await accountService.GenerateUserLoginFromToken(new TokenLoginRequest(){Token = tokenHash}); // Assert Assert.True(refreshToken.Revoked); @@ -619,7 +620,7 @@ public async Task GenerateTokenPairReturnsTokenPair() NullLogger.Instance); // Act - var tokenPair = await accountService.GenerateUserLoginFromToken(tokenHash); + var tokenPair = await accountService.GenerateUserLoginFromToken(new TokenLoginRequest(){Token = tokenHash}); // Assert Assert.NotNull(tokenPair); diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs index 0d2bd3b4..73ff1a53 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/TokenServiceTests.cs @@ -25,16 +25,19 @@ public async Task GenerateMagicLink_ReturnsLinkWithValidTokenForUser() InitialContext.Users.Add(user); await InitialContext.SaveChangesAsync(); - var tokenService = new TokenService(AssertionContext, Mock.Of()); + var hashService = new HashService(); + var tokenService = new TokenService(InitialContext, hashService); // Act var result = await tokenService.GenerateMagicLinkToken(user); // Assert + var assertionUser = await AssertionContext.Users.Include(u => u.Tokens).FirstOrDefaultAsync(); Assert.NotNull(result); Assert.NotEmpty(result); - Assert.Contains(user.Tokens, t => t.TokenHash == result); - var token = user.Tokens.First(t => t.TokenHash == result); + var hashedToken = hashService.Hash(result); + Assert.Contains(assertionUser.Tokens, t => t.TokenHash == hashedToken); + var token = assertionUser.Tokens.First(t => t.TokenHash == hashedToken); Assert.Equal(TokenType.MagicLink, token.Type); Assert.False(token.Revoked, "Token should not be revoked"); } @@ -127,42 +130,5 @@ public async Task GetValidTokenByHashAsync_ReturnsTokenByValidHash() Assert.False(result.Revoked); Assert.False(Token.Expired(result.Expires)); } - - [Fact(DisplayName = "GetValidTokenByHashAsync invalidates users refresh tokens if token is invalid")] - public async Task GetValidTokenByHashAsync_InvalidatesUsersRefreshTokensIfTokenIsInvalid() - { - // Arrange - var user = UserBuilder.DefaultCustomer().Build(); - InitialContext.Users.Add(user); - - var token = TokenBuilder.Simple().WithUser(user).WithRevoked(true).WithType(TokenType.Refresh).Build(); - var refreshToken = TokenBuilder.Simple().WithUser(user).WithType(TokenType.Refresh).Build(); - - Token[] otherTokens = - { - new ("magicLink", TokenType.MagicLink) {User = user}, - new ("reset", TokenType.ResetPassword) {User = user}, - }; - - InitialContext.Tokens.AddRange(token, refreshToken); - InitialContext.Tokens.AddRange(otherTokens); - - await InitialContext.SaveChangesAsync(); - - var hashService = new Mock(); - hashService.Setup(h => h.Hash(It.IsAny())).Returns(token.TokenHash); - - var tokenService = new TokenService(AssertionContext, hashService.Object); - - // Act & Assert - await Assert.ThrowsAsync(() => tokenService.GetValidTokenByHashAsync(token.TokenHash)); - - // Assert - Assert.True((await AssertionContext.Tokens.FirstOrDefaultAsync(t => t.Id == refreshToken.Id)).Revoked); - foreach (var otherToken in otherTokens) - { - Assert.False((await AssertionContext.Tokens.FirstOrDefaultAsync(t => t.Id == otherToken.Id)).Revoked); - } - } } } From 947bedefed26247eaab8b95a99686693e70c3c68 Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Sat, 28 Dec 2024 22:41:46 +0100 Subject: [PATCH 36/37] Fixing incorrect format --- coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs | 2 +- .../CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs | 2 +- .../CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs index e18a0848..5148c530 100644 --- a/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs +++ b/coffeecard/CoffeeCard.Library/Services/v2/TokenService.cs @@ -22,7 +22,7 @@ public TokenService(CoffeeCardContext context, IHashService hashService) public async Task GenerateMagicLinkToken(User user) { var guid = Guid.NewGuid().ToString(); - var magicLinkToken = new Token(_hashService.Hash(guid), TokenType.MagicLink){User = user}; + var magicLinkToken = new Token(_hashService.Hash(guid), TokenType.MagicLink) { User = user }; _context.Tokens.Add(magicLinkToken); await _context.SaveChangesAsync(); diff --git a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs index 61f12e0d..27791672 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs +++ b/coffeecard/CoffeeCard.Tests.Integration/Controllers/v2/LoginTest.cs @@ -76,7 +76,7 @@ public async Task Known_user_login_succeeds_returns_token() await Context.SaveChangesAsync(); // We authenticate using the non-hashed token and let the backend hash the string for us - var response = await CoffeeCardClientV2.Account_AuthenticateAsync(new TokenLoginRequest(){Token = tokenString}); + var response = await CoffeeCardClientV2.Account_AuthenticateAsync(new TokenLoginRequest() { Token = tokenString }); Assert.NotNull(response.Jwt); Assert.NotNull(response.RefreshToken); diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs index 7de81fe9..9bfa9e91 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/AccountServiceTest.cs @@ -578,7 +578,7 @@ public async Task GenerateTokenPairRevokesTokenOnUse() NullLogger.Instance); // Act - var tokenPair = await accountService.GenerateUserLoginFromToken(new TokenLoginRequest(){Token = tokenHash}); + var tokenPair = await accountService.GenerateUserLoginFromToken(new TokenLoginRequest() { Token = tokenHash }); // Assert Assert.True(refreshToken.Revoked); @@ -620,7 +620,7 @@ public async Task GenerateTokenPairReturnsTokenPair() NullLogger.Instance); // Act - var tokenPair = await accountService.GenerateUserLoginFromToken(new TokenLoginRequest(){Token = tokenHash}); + var tokenPair = await accountService.GenerateUserLoginFromToken(new TokenLoginRequest() { Token = tokenHash }); // Assert Assert.NotNull(tokenPair); From cb80e6144fcf904b659bf913bb0dfb5e4fa2eada Mon Sep 17 00:00:00 2001 From: A-Guldborg Date: Sat, 28 Dec 2024 22:48:24 +0100 Subject: [PATCH 37/37] Fix XML comment --- .../CoffeeCard.WebApi/Controllers/v2/AccountController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs index 58b3ac3a..3ce70113 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/AccountController.cs @@ -251,7 +251,7 @@ public async Task Login([FromBody] UserLoginRequest request) /// /// Authenticates the user with the token hash from a magic link /// - /// The token hash from the magic link + /// The token hash from the magic link /// A JSON Web Token used to authenticate for other endpoints and a refresh token to re-authenticate without a new magic link [HttpPost] [AllowAnonymous]