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!;
}