diff --git a/AuthenticationMicroservice.csproj b/AuthenticationMicroservice.csproj index 000f7fbafd1625cc734f5897fc817954216f93bd..d57bb8af427b8a388804b9ebc91ac6c0bde844f5 100644 --- a/AuthenticationMicroservice.csproj +++ b/AuthenticationMicroservice.csproj @@ -10,35 +10,35 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="AutoMapper" Version="12.0.1"/> - <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.0"/> - <PackageReference Include="Azure.Storage.Blobs" Version="12.16.0-beta.1"/> - <PackageReference Include="Azure.Storage.Common" Version="12.15.0-beta.1"/> - <PackageReference Include="BenchmarkDotNet" Version="0.13.5"/> - <PackageReference Include="LazyCache" Version="2.4.0"/> - <PackageReference Include="LazyCache.AspNetCore" Version="2.4.0"/> - <PackageReference Include="MailKit" Version="3.6.0"/> - <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0-preview.2.23153.2"/> - <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.0-preview.2.23153.2"/> - <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0-preview.1.23112.2"/> - <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0-preview.2.23128.3"/> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0-preview.2.23128.3"> + <PackageReference Include="AutoMapper" Version="12.0.1" /> + <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.0" /> + <PackageReference Include="Azure.Storage.Blobs" Version="12.16.0-beta.1" /> + <PackageReference Include="Azure.Storage.Common" Version="12.15.0-beta.1" /> + <PackageReference Include="BenchmarkDotNet" Version="0.13.5" /> + <PackageReference Include="LazyCache" Version="2.4.0" /> + <PackageReference Include="LazyCache.AspNetCore" Version="2.4.0" /> + <PackageReference Include="MailKit" Version="3.6.0" /> + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.5" /> + <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="7.0.5" /> + <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.5" /> + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.5" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.0-preview.2.23128.3"/> - <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0-preview.2.23128.3"/> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.0-preview.2.23128.3"> + <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.5" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.5" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.18.1"/> - <PackageReference Include="Newtonsoft.Json" Version="13.0.3"/> - <PackageReference Include="Polly" Version="7.2.3"/> - <PackageReference Include="SixLabors.ImageSharp" Version="3.0.1"/> - <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/> - <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0"/> - <PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.5.0"/> + <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.18.1" /> + <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> + <PackageReference Include="Polly" Version="7.2.3" /> + <PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> + <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" /> + <PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.5.0" /> </ItemGroup> </Project> diff --git a/Constants.cs b/Constants.cs index ac4c5231780f0e4bfe0bea1c8a7d8997f5e828a5..ddd03fe5358fd0671e03aa774b8874bb72be6813 100644 --- a/Constants.cs +++ b/Constants.cs @@ -11,8 +11,11 @@ public abstract class Constants public static class Claims { + public const string Firstname = "firstname"; + public const string Surname = "surname"; public const string Username = "username"; public const string UserSignUpDate = "userSignUpDate"; + public const string UserProfilePicture = "userProfilePicture"; public const string AccessToken = "accessToken"; public const string RefreshToken = "refreshToken"; @@ -21,7 +24,7 @@ public abstract class Constants public static class AzureBlobContainers { - public const string ProfilePictures = "profile-pictures"; + public const string ProfilePictures = "auth-profile-pictures"; } public static class AuthorizationPolicies diff --git a/Controllers/AuthenticationController.cs b/Controllers/AuthenticationController.cs index 179127f30b1dda1f7ae7853bdd7604f0a5e4a2c3..a2855d39f422f1a684110cd5a6368f7d23274330 100644 --- a/Controllers/AuthenticationController.cs +++ b/Controllers/AuthenticationController.cs @@ -209,8 +209,11 @@ public class AuthController : BaseAuthController if (authenticatedUser == null) throw new AuthenticationException("User could not be authenticated.", HttpStatusCode.Unauthorized); if (request.EmailAddress != null) - await _emailService.SendEmail("Successfully Registered", "Test", request.EmailAddress, - request.Username); + _emailService.SendEmail(new EmailDTO + { + Subject = "Successfully Registered", Body = "Test", Receiver = request.EmailAddress, + To = request.Username + }).FireAndForget(); return Ok(authenticatedUser); } catch (Exception ex) @@ -263,4 +266,22 @@ public class AuthController : BaseAuthController return BadRequest("Please upload a proper image."); return Ok(await _userService.UploadProfilePicture(UserId, profilePicture)); } + + [AllowAnonymous] + [HttpPost("SendEmail")] + [SwaggerResponse(204)] + [SwaggerResponse(400, Type = typeof(ResponseEnvelope<BadRequestObjectResult>))] + public async Task<ActionResult> SendEmail(EmailDTO emailToSend) + { + try + { + _emailService.SendEmail(emailToSend).FireAndForget(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + + return Ok(null); + } } \ No newline at end of file diff --git a/Helpers/TaskExtensions.cs b/Helpers/TaskExtensions.cs new file mode 100644 index 0000000000000000000000000000000000000000..84e08146de0b7f54948e643649d0b3992edb34cb --- /dev/null +++ b/Helpers/TaskExtensions.cs @@ -0,0 +1,12 @@ +namespace AuthenticationMicroservice.Helpers; + +public static class TaskExtensions +{ + public static void FireAndForget(this Task task, Action<Exception>? errorHandler = null) + { + task.ContinueWith(t => + { + if (t.IsFaulted && errorHandler != null) errorHandler(t.Exception); + }, TaskContinuationOptions.OnlyOnFaulted); + } +} \ No newline at end of file diff --git a/Models/DTOs/EmailDTO.cs b/Models/DTOs/EmailDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..8c24a6b10f2943bc6a960eaf639f120f4d23bc0a --- /dev/null +++ b/Models/DTOs/EmailDTO.cs @@ -0,0 +1,9 @@ +namespace AuthenticationMicroservice.Models.DTOs; + +public class EmailDTO +{ + public string? Subject { get; set; } + public string? Body { get; set; } + public string? To { get; set; } + public string? Receiver { get; set; } +} \ No newline at end of file diff --git a/Models/DTOs/UserDTO.cs b/Models/DTOs/UserDTO.cs index e672d3577221d3a9fc3351bda583d6a0c86786a2..dd138dc1e34d541da72e82df28b2b417fc40b79a 100644 --- a/Models/DTOs/UserDTO.cs +++ b/Models/DTOs/UserDTO.cs @@ -99,5 +99,7 @@ public class AuthenticatedUserDTO public long RefreshTokenExpires { get; set; } + public string? ProfilePictureUrl { get; set; } + public string? ProfilePictureSas { get; set; } } \ No newline at end of file diff --git a/Services/AuthService.cs b/Services/AuthService.cs index 971c509af5d72e3fc434f5d42b62277fd03557e2..ec35e5e2025eb7909cd20142f5d932cc1fb2c4ab 100644 --- a/Services/AuthService.cs +++ b/Services/AuthService.cs @@ -27,14 +27,16 @@ public interface IAuthService public class AuthService : IAuthService { private readonly IEmailService _emailService; + private readonly IStorageService _storageService; private readonly AuthenticationDbContext _context; private readonly IRefreshTokenService _refreshTokenService; public AuthService(AuthenticationDbContext context, IEmailService emailService, - IRefreshTokenService refreshTokenService) + IRefreshTokenService refreshTokenService, IStorageService storageService) { _context = context; _emailService = emailService; + _storageService = storageService; _refreshTokenService = refreshTokenService; } @@ -66,7 +68,11 @@ public class AuthService : IAuthService public async Task<AuthenticatedUserDTO> GenerateAuthenticatedUser(User user, string? oldToken = null) { - var newAccess = _refreshTokenService.CreateNewTokenForUser(user); + var newAccess = _refreshTokenService.CreateNewTokenForUser(user, + !string.IsNullOrWhiteSpace(user.ProfilePictureUrl) + ? _storageService.GetSasForFile(Constants.AzureBlobContainers.ProfilePictures, user.ProfilePictureUrl) + ?.Query + : null); var newRefresh = string.IsNullOrWhiteSpace(oldToken) ? await _refreshTokenService.GenerateRefreshToken(user.Id) : await _refreshTokenService.RefreshToken(oldToken, user.Id); @@ -77,6 +83,10 @@ public class AuthService : IAuthService AccessTokenExpiresIn = newAccess.ExpiresIn, RefreshToken = newRefresh!.Token, RefreshTokenExpires = newRefresh.Expires, + ProfilePictureUrl = user.ProfilePictureUrl, + ProfilePictureSas = _storageService.GetSasForFile(Constants.AzureBlobContainers.ProfilePictures, + user.ProfilePictureUrl ?? string.Empty) + ?.Query, UserId = user.Id }; @@ -110,7 +120,9 @@ public class AuthService : IAuthService _context.User.Update(user); await _context.SaveChangesAsync(); - await _emailService.SendEmail("Email Changed", "Test", request.NewEmail, user.Username); + _emailService.SendEmail(new EmailDTO + {Subject = "Email Changed", Body = "Test", To = request.NewEmail, Receiver = user.Username}) + .FireAndForget(); } public async Task ConfirmEmailAddress(int userId, string token) @@ -138,7 +150,11 @@ public class AuthService : IAuthService var user = await _context.User.FirstOrDefaultAsync(u => u.Id == userId); if (user?.EmailConfirmationToken == null) throw new AuthenticationException("User already confirmed.", HttpStatusCode.Unauthorized); - await _emailService.SendEmail("Resend Confirm Email", "Test", user.UnconfirmedEmail!, user.Username); + _emailService.SendEmail(new EmailDTO + { + Subject = "Resend Confirm Email", Body = "Test", To = user.UnconfirmedEmail!, Receiver = user.Username + }) + .FireAndForget(); } public async Task ForgotPassword(ForgotPasswordRequestDTO request) @@ -151,8 +167,9 @@ public class AuthService : IAuthService user.PasswordResetToken = NewGuid(); _context.User.Update(user); await _context.SaveChangesAsync(); - - await _emailService.SendEmail("Forgot Password", "Test", request.EmailAddress!, user.Username); + _emailService.SendEmail(new EmailDTO + {Subject = "Forgot Password", Body = "Test", To = request.EmailAddress!, Receiver = user.Username}) + .FireAndForget(); } public async Task SetPassword(SetPasswordRequestDTO request) diff --git a/Services/EmailService.cs b/Services/EmailService.cs index e19d1bf0560487a00b513fbe12ca5d1acf4b24d8..1cb28fb7ad394f3993539c83190cbc3f15e3b058 100644 --- a/Services/EmailService.cs +++ b/Services/EmailService.cs @@ -6,11 +6,12 @@ using MailKit.Net.Smtp; using Microsoft.Extensions.Options; using MimeKit; using MimeKit.Text; +using Models.DTOs; using Settings; public interface IEmailService { - Task SendEmail(string subject, string body, string to, string receiver); + Task SendEmail(EmailDTO emailDTO); } public class EmailService : IEmailService @@ -22,15 +23,15 @@ public class EmailService : IEmailService _emailConfig = emailConfig.Value; } - public async Task SendEmail(string subject, string body, string to, string receiver) + public async Task SendEmail(EmailDTO emailDTO) { if (string.IsNullOrWhiteSpace(_emailConfig.Email) && string.IsNullOrWhiteSpace(_emailConfig.Password)) throw new AuthenticationException("Email configuration failed.", HttpStatusCode.InternalServerError); var email = new MimeMessage(); email.From.Add(new MailboxAddress("aa03980", "smtp.gmail.com")); - email.To.Add(new MailboxAddress(receiver, to)); - email.Subject = subject; - email.Body = new TextPart(TextFormat.Text) {Text = body}; + email.To.Add(new MailboxAddress(emailDTO.Receiver, emailDTO.To)); + email.Subject = emailDTO.Subject; + email.Body = new TextPart(TextFormat.Text) {Text = emailDTO.Body}; using var smtp = new SmtpClient(); try { @@ -41,7 +42,7 @@ public class EmailService : IEmailService } catch (Exception ex) { - throw new AuthenticationException($"Failed to send email to: {to}. " + ex.Message, + throw new AuthenticationException($"Failed to send email to: {emailDTO.To}. " + ex.Message, HttpStatusCode.InternalServerError); } } diff --git a/Services/RefreshTokenService.cs b/Services/RefreshTokenService.cs index 240e2a8efe17b1293ad8e02284fee9c8af853030..92c32dc2cbe0a44ed0efc5cbfde7f63b8b0db6a0 100644 --- a/Services/RefreshTokenService.cs +++ b/Services/RefreshTokenService.cs @@ -17,16 +17,16 @@ using Settings; public interface IRefreshTokenService { - Task<AccessToken> CreateNewTokenForUser(int userId); - AccessToken CreateNewTokenForUser(User user); + Task<AccessToken> CreateNewTokenForUser(int userId, string? sasToken = null); + AccessToken CreateNewTokenForUser(User user, string? sasToken = null); Task<RefreshToken?> GenerateRefreshToken(int userId); Task<RefreshToken?> RefreshToken(string? oldToken, int userId); Task<RefreshToken?> GetRefreshTokenForUser(int userId); ClaimsPrincipal? GetPrincipalFromExpiredToken(string token); int GetUserIdFromExpiredToken(string token); Task InvalidateTokens(int userId, string reason); - Task<List<Claim>> GetClaimsForUser(int userId); - List<Claim> GetClaimsForUser(User user); + Task<List<Claim>> GetClaimsForUser(int userId, string? sasToken = null); + List<Claim> GetClaimsForUser(User user, string? sasToken = null); } public class RefreshTokenService : IRefreshTokenService @@ -46,14 +46,14 @@ public class RefreshTokenService : IRefreshTokenService _httpContextAccessor = httpContextAccessor; } - public async Task<AccessToken> CreateNewTokenForUser(int userId) + public async Task<AccessToken> CreateNewTokenForUser(int userId, string? sasToken = null) { var user = await _context.User.FirstOrDefaultAsync(u => u.Id == userId); if (user == null) throw new AuthenticationException("User not found.", HttpStatusCode.Unauthorized); var tokenHandler = new JwtSecurityTokenHandler(); - var claims = GetClaimsForUser(user); + var claims = GetClaimsForUser(user, sasToken); var subject = new ClaimsIdentity(claims); @@ -72,10 +72,10 @@ public class RefreshTokenService : IRefreshTokenService }; } - public AccessToken CreateNewTokenForUser(User user) + public AccessToken CreateNewTokenForUser(User user, string? sasToken = null) { var tokenHandler = new JwtSecurityTokenHandler(); - var claims = GetClaimsForUser(user); + var claims = GetClaimsForUser(user, sasToken); var subject = new ClaimsIdentity(claims); @@ -94,7 +94,7 @@ public class RefreshTokenService : IRefreshTokenService }; } - public async Task<List<Claim>> GetClaimsForUser(int userId) + public async Task<List<Claim>> GetClaimsForUser(int userId, string? sasToken = null) { var user = await _context.User.FirstOrDefaultAsync(u => u.Id == userId); if (user == null) @@ -104,20 +104,28 @@ public class RefreshTokenService : IRefreshTokenService { new(ClaimTypes.Sid, user.Id.ToString()), new(ClaimTypes.Email, user.EmailAddress?.ToLower() ?? string.Empty), + new(Constants.Claims.Firstname, user.FirstName ?? string.Empty), + new(Constants.Claims.Surname, user.Surname ?? string.Empty), new(Constants.Claims.Username, user.Username), + new(Constants.Claims.UserProfilePicture, user.ProfilePictureUrl ?? string.Empty), + new(Constants.Claims.ProfileSasToken, sasToken ?? string.Empty), new(Constants.Claims.UserSignUpDate, user.CreatedAt.ToUnixTimeSeconds().ToString()) }; return claims; } - public List<Claim> GetClaimsForUser(User user) + public List<Claim> GetClaimsForUser(User user, string? sasToken = null) { var claims = new List<Claim> { new(ClaimTypes.Sid, user.Id.ToString()), new(ClaimTypes.Email, user.EmailAddress?.ToLower() ?? string.Empty), + new(Constants.Claims.Firstname, user.FirstName ?? string.Empty), + new(Constants.Claims.Surname, user.Surname ?? string.Empty), new(Constants.Claims.Username, user.Username), + new(Constants.Claims.UserProfilePicture, user.ProfilePictureUrl ?? string.Empty), + new(Constants.Claims.ProfileSasToken, sasToken ?? string.Empty), new(Constants.Claims.UserSignUpDate, user.CreatedAt.ToUnixTimeSeconds().ToString()) }; diff --git a/Services/StorageService.cs b/Services/StorageService.cs index b01487ec04e3391b13a0db907210e582fc7d43c7..f26fc483b4429e608f58e1960e902b3004be46ff 100644 --- a/Services/StorageService.cs +++ b/Services/StorageService.cs @@ -12,7 +12,7 @@ public interface IStorageService Task<Uri> SaveImageAsJpgBlob(string containerName, string fileName, Stream input, Dictionary<string, string>? metadata = null); - string GetSasForFile(string containerName, string url, DateTimeOffset? expires = null); + Uri? GetSasForFile(string containerName, string url, DateTimeOffset? expires = null); } public class StorageService : IStorageService @@ -24,14 +24,14 @@ public class StorageService : IStorageService _connectionStrings = connectionStrings.Value; } - public string GetSasForFile(string containerName, string url, DateTimeOffset? expires = null) + public Uri? GetSasForFile(string containerName, string url, DateTimeOffset? expires = null) { url = GetFileFromUrl(url, containerName) ?? string.Empty; if (string.IsNullOrWhiteSpace(url)) - return string.Empty; + return null; var container = new BlobContainerClient(_connectionStrings.AzureBlobStorage, containerName); var blob = container.GetBlobClient(url); - var urlWithSas = blob.GenerateSasUri(BlobSasPermissions.Read, DateTimeOffset.Now.AddHours(3)).ToString(); + var urlWithSas = blob.GenerateSasUri(BlobSasPermissions.Read, DateTimeOffset.Now.AddHours(3)); return urlWithSas; } diff --git a/Services/UserService.cs b/Services/UserService.cs index a329e88b7c5eb694cd08c004fad49b974c5ad7f1..bd651a5b4e10b990ab7736c4cfbb9f9a39a64ef4 100644 --- a/Services/UserService.cs +++ b/Services/UserService.cs @@ -13,7 +13,7 @@ using Repositories; public interface IUserService { Task<User> CreateUser(UserRegisterRequestDTO request); - Task<string> UploadProfilePicture(int userId, IFormFile profilePicture); + Task<string?> UploadProfilePicture(int userId, IFormFile profilePicture); List<UserDTO> GetListOfAllUsers(); } @@ -51,7 +51,7 @@ public class UserService : IUserService return await _userRepo.CreateAndSaveAsync(newUser); } - public async Task<string> UploadProfilePicture(int userId, IFormFile profilePicture) + public async Task<string?> UploadProfilePicture(int userId, IFormFile profilePicture) { var user = _userRepo.GetByIdThrowIfNull(userId); using var ms = new MemoryStream(); @@ -62,7 +62,8 @@ public class UserService : IUserService ms); user.ProfilePictureUrl = image.ToString(); await _userRepo.UpdateAndSaveAsync(user); - return _storageService.GetSasForFile(Constants.AzureBlobContainers.ProfilePictures, image.ToString()); + return _storageService.GetSasForFile(Constants.AzureBlobContainers.ProfilePictures, image.ToString()) + ?.ToString(); } public List<UserDTO> GetListOfAllUsers()