diff --git a/Server/Configurations/SmtpCredentials.cs b/Server/Configurations/SmtpCredentials.cs new file mode 100644 index 0000000..c501dae --- /dev/null +++ b/Server/Configurations/SmtpCredentials.cs @@ -0,0 +1,9 @@ +namespace Server.Configurations; + +public class SmtpCredentials +{ + public string Host { get; set; } = null!; + public int Port { get; set; } + public string User { get; set; } = null!; + public string Password { get; set; } = null!; +} \ No newline at end of file diff --git a/Server/Constants/JwtStandardClaims.cs b/Server/Constants/JwtStandardClaims.cs new file mode 100644 index 0000000..3a3be33 --- /dev/null +++ b/Server/Constants/JwtStandardClaims.cs @@ -0,0 +1,128 @@ +namespace Server.Constants; + +/// +/// Standard JWT claims according to https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims +/// +public static class JwtStandardClaimNames +{ + /// + /// Subject - Identifier for the End-User at the Issuer. + /// + public static readonly string Sub = "sub"; + /// + /// End-User's full name in displayable form including all name parts, possibly + /// including titles and suffixes, ordered according to the End-User's locale and preferences. + /// + public static readonly string Name = "name"; + /// + /// Given name(s) or first name(s) of the End-User. Note that in some cultures, + /// people can have multiple given names; all can be present, with the names + /// being separated by space characters. + /// + public static readonly string GivenName = "given_name"; + /// + /// Surname(s) or last name(s) of the End-User. Note that in some cultures, + /// people can have multiple family names or no family name; all can be present, + /// with the names being separated by space characters. + /// + public static readonly string FamilyName = "family_name"; + /// + /// Middle name(s) of the End-User. Note that in some cultures, people can have + /// multiple middle names; all can be present, with the names being separated by + /// space characters. Also note that in some cultures, middle names are not used. + /// + public static readonly string MiddleName = "middle_name"; + /// + /// Casual name of the End-User that may or may not be the same as the given_name. + /// For instance, a nickname value of Mike might be returned alongside a given_name value of Michael. + /// + public static readonly string Nickname = "nickname"; + /// + /// Shorthand name by which the End-User wishes to be referred to at the RP, + /// such as janedoe or j.doe. This value MAY be any valid JSON string including special + /// characters such as @, /, or whitespace. The RP MUST NOT rely upon this value being unique. + /// + public static readonly string PreferredUsername = "preferred_username"; + /// + /// URL of the End-User's profile page. The contents of this Web page SHOULD be about the End-User. + /// + public static readonly string Profile = "profile"; + /// + /// URL of the End-User's profile picture. This URL MUST refer to an image file + /// (for example, a PNG, JPEG, or GIF image file), rather than to a Web page containing an image. + /// Note that this URL SHOULD specifically reference a profile photo of the End-User suitable + /// for displaying when describing the End-User, rather than an arbitrary photo taken by the End-User. + /// + public static readonly string Picture = "picture"; + /// + /// URL of the End-User's Web page or blog. This Web page SHOULD contain information + /// published by the End-User or an organization that the End-User is affiliated with. + /// + public static readonly string Website = "website"; + /// + /// End-User's preferred e-mail address. Its value MUST conform to the RFC 5322 [RFC5322] + /// addr-spec syntax. The RP MUST NOT rely upon this value being unique. + /// + public static readonly string Email = "email"; + /// + /// True if the End-User's e-mail address has been verified; otherwise false. + /// When this Claim Value is true, this means that the OP took affirmative steps + /// to ensure that this e-mail address was controlled by the End-User at the time + /// the verification was performed. The means by which an e-mail address is verified + /// is context-specific, and dependent upon the trust framework or contractual agreements + /// within which the parties are operating. + /// + public static readonly string EmailVerified = "email_verified"; + /// + /// End-User's gender. Values defined by this specification are female and male. + /// Other values MAY be used when neither of the defined values are applicable. + /// + public static readonly string Gender = "gender"; + /// + /// End-User's birthday, represented as an ISO 8601:2004 [ISO8601‑2004] YYYY-MM-DD format. + /// The year MAY be 0000, indicating that it is omitted. To represent only the year, YYYY format is allowed. + /// Note that depending on the underlying platform's date related function, providing just year can result in + /// varying month and day, so the implementers need to take this factor into account to correctly process the dates. + /// + public static readonly string BirthDate = "birthdate"; + /// + /// String from zoneinfo [zoneinfo] time zone database representing the End-User's time zone. + /// For example, Europe/Paris or America/Los_Angeles. + /// + public static readonly string ZoneInfo = "zoneinfo"; + /// + /// End-User's locale, represented as a BCP47 [RFC5646] language tag. + /// This is typically an ISO 639-1 Alpha-2 [ISO639‑1] language code in lowercase + /// and an ISO 3166-1 Alpha-2 [ISO3166‑1] country code in uppercase, separated by a dash. + /// For example, en-US or fr-CA. As a compatibility note, some implementations have used + /// an underscore as the separator rather than a dash, for example, en_US; + /// Relying Parties MAY choose to accept this locale syntax as well. + /// + public static readonly string Locale = "locale"; + /// + /// End-User's preferred telephone number. E.164 [E.164] is RECOMMENDED as the format of this Claim, + /// for example, +1 (425) 555-1212 or +56 (2) 687 2400. If the phone number contains an extension, + /// it is RECOMMENDED that the extension be represented using the RFC 3966 [RFC3966] extension syntax, + /// for example, +1 (604) 555-1234;ext=5678. + /// + public static readonly string PhoneNumber = "phone_number"; + /// + /// True if the End-User's phone number has been verified; otherwise false. + /// When this Claim Value is true, this means that the OP took affirmative steps to ensure + /// that this phone number was controlled by the End-User at the time the verification was performed. + /// The means by which a phone number is verified is context-specific, + /// and dependent upon the trust framework or contractual agreements within which the parties are operating. + /// When true, the phone_number Claim MUST be in E.164 format and any extensions MUST be represented in RFC 3966 format. + /// + public static readonly string PhoneNumberVerified = "phone_number_verified"; + /// + /// End-User's preferred postal address. The value of the address member is a JSON [RFC4627] structure + /// containing some or all of the members defined in Section 5.1.1. + /// + public static readonly string Address = "address"; + /// + /// Time the End-User's information was last updated. Its value is a JSON number representing + /// the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. + /// + public static readonly string UpdatedAt = "updated_at"; +} \ No newline at end of file diff --git a/Server/Controllers/AuthenticationController.cs b/Server/Controllers/AuthenticationController.cs index e71a788..a828c77 100644 --- a/Server/Controllers/AuthenticationController.cs +++ b/Server/Controllers/AuthenticationController.cs @@ -24,8 +24,7 @@ public class AuthenticationController : ControllerBase [HttpPost("register")] public async Task RegisterAsync([FromBody] RegistrationRequest registerRequest) { - var (succeeded, message) = - await _authService.RegisterAsync(registerRequest); + var (succeeded, message) = await _authService.RegisterAsync(registerRequest); if (!succeeded) { @@ -35,6 +34,20 @@ public class AuthenticationController : ControllerBase return Ok(new ResponseBase{ Message = message }); } + [HttpGet("confirmEmail")] + public async Task ConfirmEmailAsync([FromQuery] string email, [FromQuery] string token, + [FromQuery] string redirectionUrl) + { + var (succeeded, message) = await _authService.ConfirmEmailAsync(email, token); + + if (!succeeded) + { + return BadRequest(new ResponseBase {Message = message}); + } + + return Redirect(redirectionUrl); + } + [HttpPost("authenticate")] public async Task GetTokenAsync(AuthenticationRequest authRequest) { diff --git a/Server/Program.cs b/Server/Program.cs index 16363c9..7c04f5f 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -25,6 +25,7 @@ builder.Services.AddControllers().AddNewtonsoftJson(options => { options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Error; options.SerializerSettings.DateFormatHandling = DateFormatHandling.IsoDateFormat; }); +builder.Services.AddHttpContextAccessor(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); @@ -57,10 +58,11 @@ builder.Services.AddCors(options => { .AllowAnyHeader().AllowAnyMethod()); }); -builder.Services.AddIdentityCore(options => { +builder.Services.AddIdentityCore(options => +{ options.User.RequireUniqueEmail = true; options.Password.RequiredLength = 8; -}).AddRoles().AddEntityFrameworkStores(); +}).AddRoles().AddEntityFrameworkStores().AddDefaultTokenProviders(); // Configuration from AppSettings builder.Services.Configure(builder.Configuration.GetSection("Jwt")); @@ -81,6 +83,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])) }; }); +builder.Services.Configure(builder.Configuration.GetSection("SmtpCredentials")); builder.Services.AddAuthorization(options => { // options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); @@ -99,6 +102,7 @@ builder.Services.AddAuthorization(options => { builder.Services.AddAutoMapper(typeof(MapperInitializer)); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Server/Server.csproj b/Server/Server.csproj index d752f26..bf4404a 100644 --- a/Server/Server.csproj +++ b/Server/Server.csproj @@ -8,6 +8,7 @@ + diff --git a/Server/Services/AuthenticationService.cs b/Server/Services/AuthenticationService.cs index 24aa425..78852a3 100644 --- a/Server/Services/AuthenticationService.cs +++ b/Server/Services/AuthenticationService.cs @@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Server.Configurations; +using Server.Constants; using Server.Models; using SharedModels.Requests; using SharedModels.Responses; @@ -19,30 +20,38 @@ public class AuthenticationService : IAuthenticationService private readonly UserManager _userManager; private readonly RoleManager _roleManager; private readonly Jwt _jwt; + private readonly IHttpContextAccessor _contextAccessor; + private readonly LinkGenerator _linkGenerator; + private readonly IEmailSenderService _emailSender; public AuthenticationService(UserManager userManager, - RoleManager roleManager, IOptions jwt) + RoleManager roleManager, IOptions jwt, + IHttpContextAccessor contextAccessor, LinkGenerator linkGenerator, + IEmailSenderService emailSender) { _userManager = userManager; _roleManager = roleManager; _jwt = jwt.Value; + _contextAccessor = contextAccessor; + _linkGenerator = linkGenerator; + _emailSender = emailSender; + + _userManager.UserValidators.Clear(); } public async Task<(bool succeeded, string message)> RegisterAsync(RegistrationRequest regRequest) { - _userManager.UserValidators.Clear(); - var userWithSameEmail = await _userManager.FindByEmailAsync(regRequest.Email); if (userWithSameEmail != null) { - return (false, $"Email is already registered."); + return (false, "Email is already registered."); } var userWithSamePhone = await _userManager.Users .SingleOrDefaultAsync(u => u.PhoneNumber == regRequest.PhoneNumber); if (userWithSamePhone != null) { - return (false, $"Phone is already registered."); + return (false, "Phone is already registered."); } var user = new User @@ -55,14 +64,40 @@ public class AuthenticationService : IAuthenticationService PhoneNumber = regRequest.PhoneNumber }; - var result = await _userManager.CreateAsync(user, regRequest.Password); + var createUserResult = await _userManager.CreateAsync(user, regRequest.Password); + if (!createUserResult.Succeeded) + { + return (false, $"{createUserResult.Errors?.First().Description}"); + } + + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + var confirmationLink = _linkGenerator.GetUriByAction(_contextAccessor.HttpContext, + "confirmEmail", "authentication", + new { email = user.Email, token = token, redirectionUrl = regRequest.EmailConfirmationRedirectUrl }, + _contextAccessor.HttpContext.Request.Scheme); + + await _emailSender.SendMail(user.Email, "Email confirmation", confirmationLink); + + await _userManager.AddToRoleAsync(user, Constants.Identity.DefaultRole.ToString()); + return (true, $"User registered with email {user.Email}. Before signing in confirm your email" + + $"by following a link sent to registered email address."); + } + + public async Task<(bool succeeded, string? message)> ConfirmEmailAsync(string email, string token) + { + var user = await _userManager.FindByEmailAsync(email); + if (user == null) + { + return (false, $"Email {email} not registered"); + } + + var result = await _userManager.ConfirmEmailAsync(user, token); if (!result.Succeeded) { - return (false, $"{result.Errors?.First().Description}"); + return (false, $"Error confirming email {email} with token {token}"); } - - await _userManager.AddToRoleAsync(user, Constants.Identity.DefaultRole.ToString()); - return (true, $"User registered with email {user.Email}."); + + return (true, $"Email {email} confirmed"); } public async Task<(bool succeeded, AuthenticationResponse authResponse, @@ -186,11 +221,13 @@ public class AuthenticationService : IAuthenticationService var claims = new[] { - new Claim(JwtRegisteredClaimNames.Sub, user.Id), - new Claim(JwtRegisteredClaimNames.Name, user.LastName + user.FirstName + user.Patronymic), - new Claim(JwtRegisteredClaimNames.GivenName, user.FirstName), - new Claim(JwtRegisteredClaimNames.FamilyName, user.LastName), - new Claim(JwtRegisteredClaimNames.Email, user.Email), + new Claim(JwtStandardClaimNames.Sub, user.Id), + new Claim(JwtStandardClaimNames.Name, user.LastName + user.FirstName + user.Patronymic), + new Claim(JwtStandardClaimNames.GivenName, user.FirstName), + new Claim(JwtStandardClaimNames.FamilyName, user.LastName), + new Claim(JwtStandardClaimNames.MiddleName, user.Patronymic), + new Claim(JwtStandardClaimNames.Email, user.Email), + new Claim(JwtStandardClaimNames.EmailVerified, user.EmailConfirmed.ToString()), new Claim(JwtRegisteredClaimNames.Exp, DateTime.UtcNow.AddMinutes(_jwt.ValidityInMinutes).ToString(CultureInfo.InvariantCulture)) } .Union(userClaims) diff --git a/Server/Services/EmailSenderService.cs b/Server/Services/EmailSenderService.cs new file mode 100644 index 0000000..bfbc142 --- /dev/null +++ b/Server/Services/EmailSenderService.cs @@ -0,0 +1,51 @@ +using System.Security.Authentication; +using System.Text; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; +using Server.Configurations; + +namespace Server.Services; + +public class EmailSenderService : IEmailSenderService +{ + private readonly SmtpCredentials _smtpCredentials; + private readonly ISmtpClient _smtpClient; + private readonly IConfiguration _configuration; + + public EmailSenderService(IOptions smtpCredentials, IConfiguration configuration) + { + _configuration = configuration; + + _smtpCredentials = smtpCredentials.Value; + _smtpClient = new SmtpClient(); + _smtpClient.SslProtocols = SslProtocols.Ssl3 | SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13; + } + + public async Task<(bool succeeded, string message)> SendMail(string toEmail, string subject, string message) + { + string applicationName = _configuration.GetValue("ApplicationName"); + + MimeMessage mailMessage = new MimeMessage(); + + mailMessage.From.Add(new MailboxAddress("auto.bus", _smtpCredentials.User)); + mailMessage.To.Add(new MailboxAddress("auto.bus client", toEmail)); + mailMessage.Subject = $"{applicationName}. {subject}"; + mailMessage.Body = new TextPart(MimeKit.Text.TextFormat.Text) { Text = message}; + + try + { + await _smtpClient.ConnectAsync(_smtpCredentials.Host, _smtpCredentials.Port, true); + await _smtpClient.AuthenticateAsync(Encoding.ASCII, _smtpCredentials.User, _smtpCredentials.Password); + await _smtpClient.SendAsync(mailMessage); + await _smtpClient.DisconnectAsync(true); + return (true, "Letter has been sent successfully"); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } +} \ No newline at end of file diff --git a/Server/Services/IAuthenticationService.cs b/Server/Services/IAuthenticationService.cs index 65a1786..011ec48 100644 --- a/Server/Services/IAuthenticationService.cs +++ b/Server/Services/IAuthenticationService.cs @@ -6,10 +6,14 @@ namespace Server.Services; public interface IAuthenticationService { Task<(bool succeeded, string message)> RegisterAsync(RegistrationRequest regRequest); - - Task<(bool succeeded, AuthenticationResponse authResponse, string? refreshToken)> AuthenticateAsync(AuthenticationRequest authRequest); - Task<(bool succeeded, AuthenticationResponse authResponse, string? refreshToken)> RenewRefreshTokenAsync(string? token); + Task<(bool succeeded, string message)> ConfirmEmailAsync(string email, string token); + + Task<(bool succeeded, AuthenticationResponse authResponse, string? refreshToken)> + AuthenticateAsync(AuthenticationRequest authRequest); + + Task<(bool succeeded, AuthenticationResponse authResponse, string? refreshToken)> + RenewRefreshTokenAsync(string? token); Task RevokeRefreshToken(string? token); } diff --git a/Server/Services/IEmailSenderService.cs b/Server/Services/IEmailSenderService.cs new file mode 100644 index 0000000..598caa4 --- /dev/null +++ b/Server/Services/IEmailSenderService.cs @@ -0,0 +1,6 @@ +namespace Server.Services; + +public interface IEmailSenderService +{ + Task<(bool succeeded, string message)> SendMail(string toEmail, string subject, string message); +} \ No newline at end of file diff --git a/Server/appsettings.Development.json b/Server/appsettings.Development.json index fec3a7c..c45a1ab 100644 --- a/Server/appsettings.Development.json +++ b/Server/appsettings.Development.json @@ -5,9 +5,17 @@ "Microsoft.AspNetCore": "Warning" } }, + "AllowedHosts": "*", "ConnectionStrings": { "DefaultConnection": "host=localhost;database=auto.bus;user id=postgres;password=postgres;Include Error Detail = true" }, + "ApplicationName": "auto.bus", + "SmtpCredentials": { + "Host": "", + "Port": "", + "User": "", + "Password": "" + }, "Jwt": { "Key": "Secret which will never be exposed", "Audience": "Application URL", diff --git a/Server/appsettings.json b/Server/appsettings.json index 47ce3e1..e9224d9 100644 --- a/Server/appsettings.json +++ b/Server/appsettings.json @@ -9,6 +9,12 @@ "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=auto.bus;Username=postgres;Password=postgres;" }, + "SmtpCredentials": { + "Host": "", + "Port": "", + "User": "", + "Password": "" + }, "Jwt": { "Key": "Secret which will never be exposed", "Audience": "Application URL", diff --git a/SharedModels/Requests/RegistrationRequest.cs b/SharedModels/Requests/RegistrationRequest.cs index 7f330a8..31495d1 100644 --- a/SharedModels/Requests/RegistrationRequest.cs +++ b/SharedModels/Requests/RegistrationRequest.cs @@ -24,4 +24,7 @@ public class RegistrationRequest [Required(ErrorMessage = "Password is required")] [DataType(DataType.Password)] public string Password { get; set; } = null!; + + [Url] + public string EmailConfirmationRedirectUrl { get; set; } = null!; }