diff --git a/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommand.cs b/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommand.cs new file mode 100644 index 0000000..fd71011 --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommand.cs @@ -0,0 +1,6 @@ +using Logitar.Master.Contracts.Accounts; +using MediatR; + +namespace Logitar.Master.Application.Accounts.Commands; + +public record SignInCommand(SignInPayload Payload) : IRequest; diff --git a/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommandHandler.cs b/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommandHandler.cs new file mode 100644 index 0000000..cbcf9b7 --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/Commands/SignInCommandHandler.cs @@ -0,0 +1,62 @@ +using Logitar.Master.Contracts.Accounts; +using Logitar.Portal.Contracts.Messages; +using Logitar.Portal.Contracts.Tokens; +using Logitar.Portal.Contracts.Users; +using MediatR; + +namespace Logitar.Master.Application.Accounts.Commands; + +internal class SignInCommandHandler : IRequestHandler +{ + private const string AuthenticationTokenType = "auth+jwt"; + private const string PasswordlessTemplate = "AccountAuthentication"; + + private readonly IMessageService _messageService; + private readonly ITokenService _tokenService; + private readonly IUserService _userService; + + public SignInCommandHandler(IMessageService messageService, ITokenService tokenService, IUserService userService) + { + _messageService = messageService; + _tokenService = tokenService; + _userService = userService; + } + + public async Task Handle(SignInCommand command, CancellationToken cancellationToken) + { + SignInPayload payload = command.Payload; + // TODO(fpion): validation payload + + if (payload.Credentials != null) + { + return await HandleCredentialsAsync(payload.Credentials, payload.Locale, cancellationToken); + } + + throw new InvalidOperationException($"The {nameof(SignInPayload)} is not valid."); + } + + private async Task HandleCredentialsAsync(Credentials credentials, string locale, CancellationToken cancellationToken) + { + User? user = await _userService.FindAsync(credentials.EmailAddress, cancellationToken); + if (user == null || !user.HasPassword) + { + Email email = user?.Email ?? new(credentials.EmailAddress); + CreatedToken token = await _tokenService.CreateAsync(user?.GetSubject(), email, AuthenticationTokenType, cancellationToken); + Dictionary variables = new() + { + ["Token"] = token.Token + }; + SentMessages sentMessages = user == null + ? await _messageService.SendAsync(PasswordlessTemplate, email, locale, variables, cancellationToken) + : await _messageService.SendAsync(PasswordlessTemplate, user, locale, variables, cancellationToken); + SentMessage sentMessage = sentMessages.ToSentMessage(email); + return SignInCommandResult.AuthenticationLinkSent(sentMessage); + } + else if (credentials.Password == null) + { + return SignInCommandResult.RequirePassword(); + } + + throw new NotImplementedException(); // TODO(fpion): implement + } +} diff --git a/backend/src/Logitar.Master.Application/Accounts/IMessageService.cs b/backend/src/Logitar.Master.Application/Accounts/IMessageService.cs new file mode 100644 index 0000000..d00f71c --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/IMessageService.cs @@ -0,0 +1,10 @@ +using Logitar.Portal.Contracts.Messages; +using Logitar.Portal.Contracts.Users; + +namespace Logitar.Master.Application.Accounts; + +public interface IMessageService +{ + Task SendAsync(string template, Email email, string locale, Dictionary variables, CancellationToken cancellationToken = default); + Task SendAsync(string template, User user, string locale, Dictionary variables, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Logitar.Master.Application/Accounts/ITokenService.cs b/backend/src/Logitar.Master.Application/Accounts/ITokenService.cs new file mode 100644 index 0000000..781d7d0 --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/ITokenService.cs @@ -0,0 +1,9 @@ +using Logitar.Portal.Contracts.Tokens; +using Logitar.Portal.Contracts.Users; + +namespace Logitar.Master.Application.Accounts; + +public interface ITokenService +{ + Task CreateAsync(string? subject, Email email, string type, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Logitar.Master.Application/Accounts/IUserService.cs b/backend/src/Logitar.Master.Application/Accounts/IUserService.cs new file mode 100644 index 0000000..bb9e2dd --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/IUserService.cs @@ -0,0 +1,8 @@ +using Logitar.Portal.Contracts.Users; + +namespace Logitar.Master.Application.Accounts; + +public interface IUserService +{ + Task FindAsync(string emailAddress, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Logitar.Master.Application/Accounts/MessageExtensions.cs b/backend/src/Logitar.Master.Application/Accounts/MessageExtensions.cs new file mode 100644 index 0000000..cc72f67 --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/MessageExtensions.cs @@ -0,0 +1,75 @@ +using Logitar.Master.Contracts.Accounts; +using Logitar.Portal.Contracts.Messages; +using Logitar.Portal.Contracts.Users; +using System.Text; + +namespace Logitar.Master.Application.Accounts; + +internal static class MessageExtensions +{ + private const string Base12Table = "2345679ACDEF"; + private const string Base32Table = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + public static string GenerateConfirmationNumber(this SentMessages sentMessages) + { + if (sentMessages.Ids.Count == 0) + { + throw new ArgumentException("No message has been sent.", nameof(sentMessages)); + } + else if (sentMessages.Ids.Count > 1) + { + throw new ArgumentException("More than one message have been sent.", nameof(sentMessages)); + } + + StringBuilder number = new(); + + string id = Convert.ToBase64String(sentMessages.Ids.Single().ToByteArray()); + int total = 0; + for (int i = 0; i <= 2; i++) + { + total += (int)Math.Pow(64, 2 - i) * GetBase64Value(id[i]); + } + for (int i = 3; i >= 0; i--) + { + int divider = (int)Math.Pow(32, i); + int value = total / divider; + total %= divider; + number.Append(GetBase32Character(value)); + } + DateTime now = DateTime.UtcNow; + number.Append('-').Append((now.Year % 100).ToString("D2")).Append(now.Month.ToString("D2")).Append(now.Day.ToString("D2")).Append('-'); + + int minuteRange = ((now.Hour * 60) + now.Minute) / 10; + number.Append(GetBase12Character(minuteRange / 12)); + number.Append(GetBase12Character(minuteRange % 12)); + + return number.ToString(); + } + private static char GetBase12Character(int value) => Base12Table[value]; + private static char GetBase32Character(int value) => Base32Table[value]; + private static int GetBase64Value(char c) + { + if (c >= 'A' && c <= 'Z') + { + return c - 'A'; + } + else if (c >= 'a' && c <= 'z') + { + return c - 'a' + 26; + } + else if (c >= '0' && c <= '9') + { + return c - '0' + 52; + } + else if (c == '+') + { + return 62; + } + return 63; + } + + public static SentMessage ToSentMessage(this SentMessages sentMessages, Email email) + { + return new SentMessage(sentMessages.GenerateConfirmationNumber(), ContactType.Email, email.Address); + } +} diff --git a/backend/src/Logitar.Master.Application/Accounts/UserExtensions.cs b/backend/src/Logitar.Master.Application/Accounts/UserExtensions.cs new file mode 100644 index 0000000..e53dcc7 --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/UserExtensions.cs @@ -0,0 +1,8 @@ +using Logitar.Portal.Contracts.Users; + +namespace Logitar.Master.Application.Accounts; + +internal static class UserExtensions +{ + public static string GetSubject(this User user) => user.Id.ToString(); +} diff --git a/backend/src/Logitar.Master.Contracts/Accounts/ContactType.cs b/backend/src/Logitar.Master.Contracts/Accounts/ContactType.cs new file mode 100644 index 0000000..a59fc73 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Accounts/ContactType.cs @@ -0,0 +1,7 @@ +namespace Logitar.Master.Contracts.Accounts; + +public enum ContactType +{ + Email = 0, + Phone = 1 +} diff --git a/backend/src/Logitar.Master.Contracts/Accounts/Credentials.cs b/backend/src/Logitar.Master.Contracts/Accounts/Credentials.cs new file mode 100644 index 0000000..2dac99c --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Accounts/Credentials.cs @@ -0,0 +1,17 @@ +namespace Logitar.Master.Contracts.Accounts; + +public record Credentials +{ + public string EmailAddress { get; set; } + public string? Password { get; set; } + + public Credentials() : this(string.Empty) + { + } + + public Credentials(string emailAddress, string? password = null) + { + EmailAddress = emailAddress; + Password = password; + } +} diff --git a/backend/src/Logitar.Master.Contracts/Accounts/SentMessage.cs b/backend/src/Logitar.Master.Contracts/Accounts/SentMessage.cs new file mode 100644 index 0000000..bc54e55 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Accounts/SentMessage.cs @@ -0,0 +1,23 @@ +namespace Logitar.Master.Contracts.Accounts; + +public record SentMessage +{ + public string ConfirmationNumber { get; set; } + public ContactType ContactType { get; set; } + public string MaskedContact { get; set; } + + public SentMessage() : this(string.Empty, string.Empty) + { + } + + public SentMessage(string confirmationNumber, string maskedContact) : this(confirmationNumber, default, maskedContact) + { + } + + public SentMessage(string confirmationNumber, ContactType contactType, string maskedContact) + { + ConfirmationNumber = confirmationNumber; + ContactType = contactType; + MaskedContact = maskedContact; + } +} diff --git a/backend/src/Logitar.Master.Contracts/Accounts/SignInPayload.cs b/backend/src/Logitar.Master.Contracts/Accounts/SignInPayload.cs new file mode 100644 index 0000000..14c3fb2 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Accounts/SignInPayload.cs @@ -0,0 +1,17 @@ +namespace Logitar.Master.Contracts.Accounts; + +public record SignInPayload +{ + public string Locale { get; set; } + + public Credentials? Credentials { get; set; } + + public SignInPayload() : this(string.Empty) + { + } + + public SignInPayload(string locale) + { + Locale = locale; + } +} diff --git a/backend/src/Logitar.Master.Contracts/Accounts/SignInResult.cs b/backend/src/Logitar.Master.Contracts/Accounts/SignInResult.cs new file mode 100644 index 0000000..8206bbb --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Accounts/SignInResult.cs @@ -0,0 +1,29 @@ +using Logitar.Portal.Contracts.Sessions; + +namespace Logitar.Master.Contracts.Accounts; + +public record SignInCommandResult +{ + public SentMessage? AuthenticationLinkSentTo { get; set; } + public bool IsPasswordRequired { get; set; } + public Session? Session { get; set; } + + public SignInCommandResult() + { + } + + public static SignInCommandResult AuthenticationLinkSent(SentMessage sentMessage) => new() + { + AuthenticationLinkSentTo = sentMessage + }; + + public static SignInCommandResult RequirePassword() => new() + { + IsPasswordRequired = true + }; + + public static SignInCommandResult Succeed(Session session) => new() + { + Session = session + }; +} diff --git a/backend/src/Logitar.Master/Authentication/BearerTokenService.cs b/backend/src/Logitar.Master/Authentication/BearerTokenService.cs new file mode 100644 index 0000000..6d02e17 --- /dev/null +++ b/backend/src/Logitar.Master/Authentication/BearerTokenService.cs @@ -0,0 +1,26 @@ +using Logitar.Master.Models.Account; +using Logitar.Master.Settings; +using Logitar.Portal.Contracts.Sessions; + +namespace Logitar.Master.Authentication; + +internal class BearerTokenService : IBearerTokenService +{ + private readonly BearerTokenSettings _settings; + + public BearerTokenService(BearerTokenSettings settings) + { + _settings = settings; + } + + public TokenResponse GetTokenResponse(Session session) + { + string accessToken = ""; // TODO(fpion): implement + + return new TokenResponse(accessToken, _settings.TokenType) + { + ExpiresIn = _settings.LifetimeSeconds, + RefreshToken = session.RefreshToken + }; + } +} diff --git a/backend/src/Logitar.Master/Authentication/IBearerTokenService.cs b/backend/src/Logitar.Master/Authentication/IBearerTokenService.cs new file mode 100644 index 0000000..b141fac --- /dev/null +++ b/backend/src/Logitar.Master/Authentication/IBearerTokenService.cs @@ -0,0 +1,9 @@ +using Logitar.Master.Models.Account; +using Logitar.Portal.Contracts.Sessions; + +namespace Logitar.Master.Authentication; + +public interface IBearerTokenService +{ + TokenResponse GetTokenResponse(Session session); +} diff --git a/backend/src/Logitar.Master/Controllers/AccountController.cs b/backend/src/Logitar.Master/Controllers/AccountController.cs new file mode 100644 index 0000000..3fcfd0c --- /dev/null +++ b/backend/src/Logitar.Master/Controllers/AccountController.cs @@ -0,0 +1,51 @@ +using Logitar.Master.Application.Accounts.Commands; +using Logitar.Master.Authentication; +using Logitar.Master.Contracts.Accounts; +using Logitar.Master.Models.Account; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace Logitar.Master.Controllers; + +[ApiController] +public class AccountController : ControllerBase +{ + private readonly IBearerTokenService _bearerTokenService; + private readonly ISender _sender; + + public AccountController(IBearerTokenService bearerTokenService, ISender sender) + { + _bearerTokenService = bearerTokenService; + _sender = sender; + } + + [HttpPost("/auth/sign/in")] + public async Task> SignInAsync([FromBody] SignInPayload payload, CancellationToken cancellationToken) + { + SignInCommandResult result = await _sender.Send(new SignInCommand(payload), cancellationToken); + if (result.Session != null) + { + // TODO(fpion): SignIn + } + + return Ok(new SignInResponse(result)); + } + + [HttpPost("/auth/token")] + public async Task> TokenAsync([FromBody] GetTokenPayload payload, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(payload.RefreshToken)) + { + throw new NotImplementedException(); // TODO(fpion): renew session + } + + SignInCommandResult result = await _sender.Send(new SignInCommand(payload), cancellationToken); + GetTokenResponse response = new(result); + if (result.Session != null) + { + response.TokenResponse = _bearerTokenService.GetTokenResponse(result.Session); + } + + return Ok(response); + } +} diff --git a/backend/src/Logitar.Master/Models/Account/CurrentUser.cs b/backend/src/Logitar.Master/Models/Account/CurrentUser.cs new file mode 100644 index 0000000..333aac3 --- /dev/null +++ b/backend/src/Logitar.Master/Models/Account/CurrentUser.cs @@ -0,0 +1,25 @@ +using Logitar.Portal.Contracts.Users; + +namespace Logitar.Master.Models.Account; + +public record CurrentUser +{ + public string DisplayName { get; set; } + public string? EmailAddress { get; set; } + public string? PictureUrl { get; set; } + + public CurrentUser() : this(string.Empty) + { + } + + public CurrentUser(string displayName) + { + DisplayName = displayName; + } + + public CurrentUser(User user) : this(user.FullName ?? user.UniqueName) + { + EmailAddress = user.Email?.Address; + PictureUrl = user.Picture; + } +} diff --git a/backend/src/Logitar.Master/Models/Account/GetTokenPayload.cs b/backend/src/Logitar.Master/Models/Account/GetTokenPayload.cs new file mode 100644 index 0000000..14ea18d --- /dev/null +++ b/backend/src/Logitar.Master/Models/Account/GetTokenPayload.cs @@ -0,0 +1,9 @@ +using Logitar.Master.Contracts.Accounts; + +namespace Logitar.Master.Models.Account; + +public record GetTokenPayload : SignInPayload +{ + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } +} diff --git a/backend/src/Logitar.Master/Models/Account/GetTokenResponse.cs b/backend/src/Logitar.Master/Models/Account/GetTokenResponse.cs new file mode 100644 index 0000000..2879fb1 --- /dev/null +++ b/backend/src/Logitar.Master/Models/Account/GetTokenResponse.cs @@ -0,0 +1,20 @@ +using Logitar.Master.Contracts.Accounts; + +namespace Logitar.Master.Models.Account; + +public record GetTokenResponse +{ + public SentMessage? AuthenticationLinkSentTo { get; set; } + public bool IsPasswordRequired { get; set; } + public TokenResponse? TokenResponse { get; set; } + + public GetTokenResponse() + { + } + + public GetTokenResponse(SignInCommandResult result) + { + AuthenticationLinkSentTo = result.AuthenticationLinkSentTo; + IsPasswordRequired = result.IsPasswordRequired; + } +} diff --git a/backend/src/Logitar.Master/Models/Account/SignInResponse.cs b/backend/src/Logitar.Master/Models/Account/SignInResponse.cs new file mode 100644 index 0000000..556f5ad --- /dev/null +++ b/backend/src/Logitar.Master/Models/Account/SignInResponse.cs @@ -0,0 +1,25 @@ +using Logitar.Master.Contracts.Accounts; + +namespace Logitar.Master.Models.Account; + +public record SignInResponse +{ + public SentMessage? AuthenticationLinkSentTo { get; set; } + public bool IsPasswordRequired { get; set; } + public CurrentUser? CurrentUser { get; set; } + + public SignInResponse() + { + } + + public SignInResponse(SignInCommandResult result) + { + AuthenticationLinkSentTo = result.AuthenticationLinkSentTo; + IsPasswordRequired = result.IsPasswordRequired; + + if (result.Session != null) + { + CurrentUser = new CurrentUser(result.Session.User); + } + } +} diff --git a/backend/src/Logitar.Master/Models/Account/TokenResponse.cs b/backend/src/Logitar.Master/Models/Account/TokenResponse.cs new file mode 100644 index 0000000..0bdfe68 --- /dev/null +++ b/backend/src/Logitar.Master/Models/Account/TokenResponse.cs @@ -0,0 +1,29 @@ +namespace Logitar.Master.Models.Account; + +public record TokenResponse +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + [JsonPropertyName("token_type")] + public string TokenType { get; set; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + public TokenResponse() : this(string.Empty, string.Empty) + { + } + + public TokenResponse(string accessToken, string tokenType) + { + AccessToken = accessToken; + TokenType = tokenType; + } +} diff --git a/backend/src/Logitar.Master/Settings/BearerTokenSettings.cs b/backend/src/Logitar.Master/Settings/BearerTokenSettings.cs new file mode 100644 index 0000000..d23d81f --- /dev/null +++ b/backend/src/Logitar.Master/Settings/BearerTokenSettings.cs @@ -0,0 +1,16 @@ +namespace Logitar.Master.Settings; + +internal record BearerTokenSettings +{ + public int LifetimeSeconds { get; set; } + public string TokenType { get; set; } + + public BearerTokenSettings() : this(string.Empty) + { + } + + public BearerTokenSettings(string tokenType) + { + TokenType = tokenType; + } +} diff --git a/backend/src/Logitar.Master/Startup.cs b/backend/src/Logitar.Master/Startup.cs index e10acdd..4a1be1d 100644 --- a/backend/src/Logitar.Master/Startup.cs +++ b/backend/src/Logitar.Master/Startup.cs @@ -1,4 +1,5 @@ using Logitar.EventSourcing.EntityFrameworkCore.Relational; +using Logitar.Master.Authentication; using Logitar.Master.EntityFrameworkCore; using Logitar.Master.EntityFrameworkCore.SqlServer; using Logitar.Master.Extensions; @@ -28,6 +29,10 @@ public override void ConfigureServices(IServiceCollection services) services.AddSingleton(corsSettings); services.AddCors(corsSettings); + BearerTokenSettings bearerTokenSettings = _configuration.GetSection("BearerToken").Get() ?? new(); + services.AddSingleton(bearerTokenSettings); + services.AddSingleton(); + services.AddApplicationInsightsTelemetry(); IHealthChecksBuilder healthChecks = services.AddHealthChecks(); diff --git a/backend/src/Logitar.Master/appsettings.json b/backend/src/Logitar.Master/appsettings.json index e84bf77..a010689 100644 --- a/backend/src/Logitar.Master/appsettings.json +++ b/backend/src/Logitar.Master/appsettings.json @@ -1,5 +1,9 @@ { "AllowedHosts": "*", + "BearerToken": { + "LifetimeSeconds": 900, + "TokenType": "Bearer" + }, "Caching": { "ActorLifetime": "00:15:00" },