From b7e39b59fd0fc7b5610ebff29035bf622079e0d8 Mon Sep 17 00:00:00 2001 From: ivarlovlie Date: Wed, 5 Oct 2022 20:45:21 +0800 Subject: refactor: Change file structure --- code/api/src/Services/MailService.cs | 49 ++++++++ code/api/src/Services/PasswordResetService.cs | 115 +++++++++++++++++++ code/api/src/Services/UserService.cs | 50 ++++++++ code/api/src/Services/VaultService.cs | 158 ++++++++++++++++++++++++++ 4 files changed, 372 insertions(+) create mode 100644 code/api/src/Services/MailService.cs create mode 100644 code/api/src/Services/PasswordResetService.cs create mode 100644 code/api/src/Services/UserService.cs create mode 100644 code/api/src/Services/VaultService.cs (limited to 'code/api/src/Services') diff --git a/code/api/src/Services/MailService.cs b/code/api/src/Services/MailService.cs new file mode 100644 index 0000000..c08cb84 --- /dev/null +++ b/code/api/src/Services/MailService.cs @@ -0,0 +1,49 @@ +namespace IOL.GreatOffice.Api.Services; + +public class MailService +{ + private readonly ILogger _logger; + private static string _emailHost; + private static int _emailPort; + private static string _emailUser; + private static string _emailPassword; + + public MailService(VaultService vaultService, ILogger logger) { + var configuration = vaultService.GetCurrentAppConfiguration(); + _logger = logger; + _emailHost = configuration.SMTP_HOST; + _emailPort = Convert.ToInt32(configuration.SMTP_PORT); + _emailUser = configuration.SMTP_USER; + _emailPassword = configuration.SMTP_PASSWORD; + } + + /// + /// Send an email. + /// + /// + public void SendMail(MailMessage message) { + using var smtpClient = new SmtpClient { + Host = _emailHost, + EnableSsl = _emailPort == 587, + Port = _emailPort, + Credentials = new NetworkCredential { + UserName = _emailUser, + Password = _emailPassword, + } + }; + var configurationString = JsonSerializer.Serialize(new { + smtpClient.Host, + smtpClient.EnableSsl, + smtpClient.Port, + UserName = _emailUser.HasValue() ? "**REDACTED**" : "**MISSING**", + Password = _emailPassword.HasValue() ? "**REDACTED**" : "**MISSING**", + }, + new JsonSerializerOptions { + WriteIndented = true + }); + + _logger.LogDebug("SmtpClient was instansiated with the following configuration\n" + configurationString); + + smtpClient.Send(message); + } +} \ No newline at end of file diff --git a/code/api/src/Services/PasswordResetService.cs b/code/api/src/Services/PasswordResetService.cs new file mode 100644 index 0000000..1b4f147 --- /dev/null +++ b/code/api/src/Services/PasswordResetService.cs @@ -0,0 +1,115 @@ +namespace IOL.GreatOffice.Api.Services; + +public class PasswordResetService +{ + private readonly AppDbContext _context; + private readonly MailService _mailService; + private readonly AppConfiguration _configuration; + private readonly ILogger _logger; + + + public PasswordResetService( + AppDbContext context, + VaultService vaultService, + ILogger logger, + MailService mailService + ) { + _context = context; + _configuration = vaultService.GetCurrentAppConfiguration(); + _logger = logger; + _mailService = mailService; + } + + public async Task GetRequestAsync(Guid id, CancellationToken cancellationToken = default) { + var request = await _context.ForgotPasswordRequests + .Include(c => c.User) + .SingleOrDefaultAsync(c => c.Id == id, cancellationToken); + if (request == default) { + return default; + } + + _logger.LogInformation($"Found password reset request for user: {request.User.Username}, expires at {request.ExpirationDate} (in {request.ExpirationDate.Subtract(AppDateTime.UtcNow).Minutes} minutes)."); + return request; + } + + public async Task FullFillRequestAsync(Guid id, string newPassword, CancellationToken cancellationToken = default) { + var request = await GetRequestAsync(id, cancellationToken); + if (request == default) { + throw new ForgotPasswordRequestNotFoundException("Request with id: " + id + " was not found"); + } + + var user = _context.Users.SingleOrDefault(c => c.Id == request.User.Id); + if (user == default) { + throw new UserNotFoundException("User with id: " + request.User.Id + " was not found"); + } + + user.HashAndSetPassword(newPassword); + _context.Users.Update(user); + await _context.SaveChangesAsync(cancellationToken); + _logger.LogInformation($"Fullfilled password reset request for user: {request.User.Username}"); + await DeleteRequestsForUserAsync(user.Id, cancellationToken); + return true; + } + + + public async Task AddRequestAsync(User user, TimeZoneInfo requestTz, CancellationToken cancellationToken = default) { + await DeleteRequestsForUserAsync(user.Id, cancellationToken); + var request = new ForgotPasswordRequest(user); + _context.ForgotPasswordRequests.Add(request); + await _context.SaveChangesAsync(cancellationToken); + var portalUrl = _configuration.PORTAL_URL; + var emailFromAddress = _configuration.EMAIL_FROM_ADDRESS; + var emailFromDisplayName = _configuration.EMAIL_FROM_DISPLAY_NAME; + var zonedExpirationDate = TimeZoneInfo.ConvertTimeBySystemTimeZoneId(request.ExpirationDate, requestTz.Id); + var message = new MailMessage { + From = new MailAddress(emailFromAddress, emailFromDisplayName), + To = { + new MailAddress(user.Username) + }, + Subject = "Reset password - Greatoffice", + Body = @$" +Hi {user.Username} + +Go to the following link to set a new password. + +{portalUrl}/reset-password/{request.Id} + +The link expires at {zonedExpirationDate:yyyy-MM-dd hh:mm}. +If you did not request a password reset, no action is required. +" + }; + +#pragma warning disable 4014 + Task.Run(() => { +#pragma warning restore 4014 + _mailService.SendMail(message); + _logger.LogInformation($"Added password reset request for user: {request.User.Username}, expires in {request.ExpirationDate.Subtract(AppDateTime.UtcNow)}."); + }, + cancellationToken); + } + + public async Task DeleteRequestsForUserAsync(Guid userId, CancellationToken cancellationToken = default) { + var requestsToRemove = _context.ForgotPasswordRequests.Where(c => c.UserId == userId).ToList(); + if (!requestsToRemove.Any()) return; + _context.ForgotPasswordRequests.RemoveRange(requestsToRemove); + await _context.SaveChangesAsync(cancellationToken); + _logger.LogInformation($"Deleted {requestsToRemove.Count} password reset requests for user: {userId}."); + } + + + public async Task DeleteStaleRequestsAsync(CancellationToken cancellationToken = default) { + var deleteCount = 0; + foreach (var request in _context.ForgotPasswordRequests.Where(c => c.IsExpired)) { + if (!request.IsExpired) { + continue; + } + + _context.ForgotPasswordRequests.Remove(request); + deleteCount++; + _logger.LogInformation($"Marking password reset request with id: {request.Id} for deletion, expiration date was {request.ExpirationDate}."); + } + + await _context.SaveChangesAsync(cancellationToken); + _logger.LogInformation($"Deleted {deleteCount} stale password reset requests."); + } +} \ No newline at end of file diff --git a/code/api/src/Services/UserService.cs b/code/api/src/Services/UserService.cs new file mode 100644 index 0000000..6db663a --- /dev/null +++ b/code/api/src/Services/UserService.cs @@ -0,0 +1,50 @@ +namespace IOL.GreatOffice.Api.Services; + +public class UserService +{ + private readonly PasswordResetService _passwordResetService; + + /// + /// Provides methods to perform common operations on user data. + /// + /// + public UserService(PasswordResetService passwordResetService) { + _passwordResetService = passwordResetService; + } + + /// + /// Log in a user. + /// + /// + /// + /// + public async Task LogInUser(HttpContext httpContext, User user, bool persist = false) { + var claims = new List { + new(AppClaims.USER_ID, user.Id.ToString()), + new(AppClaims.NAME, user.Username), + }; + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + var authenticationProperties = new AuthenticationProperties { + AllowRefresh = true, + IssuedUtc = DateTimeOffset.UtcNow, + }; + + if (persist) { + authenticationProperties.ExpiresUtc = DateTimeOffset.UtcNow.AddMonths(6); + authenticationProperties.IsPersistent = true; + } + + await httpContext.SignInAsync(principal, authenticationProperties); + await _passwordResetService.DeleteRequestsForUserAsync(user.Id); + } + + /// + /// Log out a user. + /// + /// + public async Task LogOutUser(HttpContext httpContext) { + await httpContext.SignOutAsync(); + } +} diff --git a/code/api/src/Services/VaultService.cs b/code/api/src/Services/VaultService.cs new file mode 100644 index 0000000..732911a --- /dev/null +++ b/code/api/src/Services/VaultService.cs @@ -0,0 +1,158 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace IOL.GreatOffice.Api.Services; + +public class VaultService +{ + private readonly HttpClient _client; + private readonly IMemoryCache _cache; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private int CACHE_TTL { get; set; } + + public VaultService(HttpClient client, IConfiguration configuration, IMemoryCache cache, ILogger logger) { + var token = configuration.GetValue(AppEnvironmentVariables.VAULT_TOKEN); + var vaultUrl = configuration.GetValue(AppEnvironmentVariables.VAULT_URL); + CACHE_TTL = configuration.GetValue(AppEnvironmentVariables.VAULT_CACHE_TTL, 60 * 60 * 12); + if (token.IsNullOrWhiteSpace()) throw new ApplicationException("VAULT_TOKEN is empty"); + if (vaultUrl.IsNullOrWhiteSpace()) throw new ApplicationException("VAULT_URL is empty"); + client.DefaultRequestHeaders.Add("X-Vault-Token", token); + client.BaseAddress = new Uri(vaultUrl); + _client = client; + _cache = cache; + _configuration = configuration; + _logger = logger; + } + + public static object Data { get; set; } + + public T Get(string path) { + var result = _cache.GetOrCreate(AppConstants.VAULT_CACHE_KEY, + cacheEntry => { + cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(CACHE_TTL); + var getSecretResponse = _client.GetFromJsonAsync>("/v1/kv/data/" + path).Result; + + if (getSecretResponse == null) { + return default; + } + + Log.Debug("Setting new Vault cache, " + + new { + PATH = path, + CACHE_TTL, + Data = JsonSerializer.Serialize(getSecretResponse.Data.Data) + }); + return getSecretResponse.Data.Data ?? default; + }); + return result; + } + + public T Refresh(string path) { + _cache.Remove(AppConstants.VAULT_CACHE_KEY); + CACHE_TTL = _configuration.GetValue(AppEnvironmentVariables.VAULT_CACHE_TTL, 60 * 60 * 12); + return Get(path); + } + + public async Task RenewTokenAsync(string token) { + var response = await _client.PostAsJsonAsync("v1/auth/token/renew", + new { + Token = token + }); + if (response.IsSuccessStatusCode) { + return await response.Content.ReadFromJsonAsync(); + } + + return default; + } + + public AppConfiguration GetCurrentAppConfiguration() { + var path = _configuration.GetValue(AppEnvironmentVariables.MAIN_CONFIG_SHEET); + var result = Get(path); + var overwrites = new { + DB_HOST = _configuration.GetValue("OVERWRITE_DB_HOST", string.Empty), + DB_PORT = _configuration.GetValue("OVERWRITE_DB_PORT", string.Empty), + DB_USER = _configuration.GetValue("OVERWRITE_DB_USER", string.Empty), + DB_PASSWORD = _configuration.GetValue("OVERWRITE_DB_PASSWORD", string.Empty), + DB_NAME = _configuration.GetValue("OVERWRITE_DB_NAME", string.Empty), + }; + if (overwrites.DB_HOST.HasValue()) { + _logger.LogInformation("OVERWRITE_DB_HOST is specified, using it's value: " + overwrites.DB_HOST); + result.DB_HOST = overwrites.DB_HOST; + } + + if (overwrites.DB_PORT.HasValue()) { + _logger.LogInformation("OVERWRITE_DB_PORT is specified, using it's value: " + overwrites.DB_PORT); + result.DB_PORT = overwrites.DB_PORT; + } + + if (overwrites.DB_USER.HasValue()) { + _logger.LogInformation("OVERWRITE_DB_USER is specified, using it's value: " + overwrites.DB_USER); + result.DB_USER = overwrites.DB_USER; + } + + if (overwrites.DB_PASSWORD.HasValue()) { + _logger.LogInformation("OVERWRITE_DB_PASSWORD is specified, using it's value: " + "(redacted)"); + result.DB_PASSWORD = overwrites.DB_PASSWORD; + } + + if (overwrites.DB_NAME.HasValue()) { + _logger.LogInformation("OVERWRITE_DB_NAME is specified, using it's value: " + overwrites.DB_NAME); + result.DB_NAME = overwrites.DB_NAME; + } + + return result; + } + + public AppConfiguration RefreshCurrentAppConfiguration() { + var path = _configuration.GetValue(AppEnvironmentVariables.MAIN_CONFIG_SHEET); + return Refresh(path); + } + + public class RenewTokenResponse + { + public Guid RequestId { get; set; } + public string LeaseId { get; set; } + public bool Renewable { get; set; } + public long LeaseDuration { get; set; } + public object Data { get; set; } + public object WrapInfo { get; set; } + public List Warnings { get; set; } + public Auth Auth { get; set; } + } + + public class Auth + { + public string ClientToken { get; set; } + public string Accessor { get; set; } + public List Policies { get; set; } + public List TokenPolicies { get; set; } + public object Metadata { get; set; } + public long LeaseDuration { get; set; } + public bool Renewable { get; set; } + public string EntityId { get; set; } + public string TokenType { get; set; } + public bool Orphan { get; set; } + public object MfaRequirement { get; set; } + public long NumUses { get; set; } + } + + public class GetSecretResponse + { + public VaultSecret Data { get; set; } + } + + public class VaultSecret + { + public T Data { get; set; } + public VaultSecretMetadata Metadata { get; set; } + } + + public class VaultSecretMetadata + { + public DateTimeOffset CreatedTime { get; set; } + public object CustomMetadata { get; set; } + public string DeletionTime { get; set; } + public bool Destroyed { get; set; } + public long Version { get; set; } + } +} -- cgit v1.3