Skip to content

Commit

Permalink
sms authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
lordspinach authored and Insei committed Sep 20, 2023
1 parent 741a4e6 commit 91793a0
Show file tree
Hide file tree
Showing 36 changed files with 937 additions and 43 deletions.
2 changes: 1 addition & 1 deletion src/Kirel.Identity.Client.Blazor.Pages/Login.razor
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

private async void HandleLogin()
{
await AuthenticationService.LoginByPasswordAsync(_model.Username, _model.Password);
await AuthenticationService.LoginByPasswordAsync("username", _model.Username, _model.Password);
NavigationManager.NavigateTo(NavigationManager.BaseUri);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ public KirelClientJwtAuthenticationService(
}

/// <inheritdoc />
public async Task LoginByPasswordAsync(string login, string password)
public async Task LoginByPasswordAsync(string type, string login, string password)
{
var tokenDto = await _httpClient.GetFromJsonAsync<JwtTokenDto>(
$"{_url}?login={login}&password={password}");
$"{_url}?type={type}&login={login}&password={password}");
//TODO: Add logging here
if (tokenDto == null)
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ public interface IClientAuthenticationService
/// <summary>
/// Start user session by login with a password.
/// </summary>
/// <param name="type"> Authentication type </param>
/// <param name="login"> User login </param>
/// <param name="password"> User password </param>
/// <returns> Awaitable task </returns>
Task LoginByPasswordAsync(string login, string password);
Task LoginByPasswordAsync(string type, string login, string password);

/// <summary>
/// Gets session expiration time.
Expand Down
15 changes: 15 additions & 0 deletions src/Kirel.Identity.Core/Interfaces/ISmsSender.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Kirel.Identity.Core.Interfaces;

/// <summary>
/// Interface for sms sender
/// </summary>
public interface ISmsSender
{
/// <summary>
/// Sends sms message to the given phone number
/// </summary>
/// <param name="text"></param>
/// <param name="phoneNumber"></param>
/// <returns></returns>
Task SendSms(string text, string phoneNumber);
}
38 changes: 22 additions & 16 deletions src/Kirel.Identity.Core/Services/KirelAuthenticationService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Kirel.Identity.Core.Models;
using Kirel.Identity.DTOs;
using Kirel.Identity.Exceptions;
using Microsoft.AspNetCore.Identity;

Expand All @@ -11,15 +12,15 @@ namespace Kirel.Identity.Core.Services;
/// <typeparam name="TUser"> User entity type. </typeparam>
/// <typeparam name="TRole"> Role entity type. </typeparam>
/// <typeparam name="TUserRole"> Role user entity type. </typeparam>
/// <typeparam name="TUserClaim"> User claim type. </typeparam>
/// <typeparam name="TRoleClaim"> Role claim type. </typeparam>
/// <typeparam name="TUserClaim"> User claim type</typeparam>
/// <typeparam name="TRoleClaim"> Role claim type</typeparam>
public class KirelAuthenticationService<TKey, TUser, TRole, TUserRole, TUserClaim, TRoleClaim>
where TKey : IComparable, IComparable<TKey>, IEquatable<TKey>
where TUser : KirelIdentityUser<TKey, TUser, TRole, TUserRole, TUserClaim, TRoleClaim>
where TRole : KirelIdentityRole<TKey, TRole, TUser, TUserRole, TRoleClaim, TUserClaim>
where TUserRole : KirelIdentityUserRole<TKey, TUserRole, TUser, TRole, TUserClaim, TRoleClaim>
where TRoleClaim : KirelIdentityRoleClaim<TKey>
where TUserClaim : KirelIdentityUserClaim<TKey>
where TUserClaim : IdentityUserClaim<TKey>
where TRoleClaim : IdentityRoleClaim<TKey>
{
/// <summary>
/// Identity user manager
Expand All @@ -38,24 +39,29 @@ public KirelAuthenticationService(UserManager<TUser> userManager)
/// <summary>
/// Provides the ability to get the identity user after checking the login and password
/// </summary>
/// <param name="login"> User login </param>
/// <param name="password"> User password </param>
/// <param name="dto"> User authentication dto </param>
/// <returns> User class instance </returns>
/// <exception cref="KirelAuthenticationException"> Returned if the user is not found or if the password is incorrect </exception>
/// <exception cref="KirelIdentityStoreException"> If user or role managers fails on store based operations </exception>
public async Task<TUser> LoginByPassword(string login, string password)
public async Task<TUser> LoginByPassword(KirelUserAuthenticationDto dto)
{
if (string.IsNullOrEmpty(login) || string.IsNullOrEmpty(password))
throw new KirelAuthenticationException("Login or password cannot be empty");
var user = await UserManager.FindByNameAsync(login);
switch (user)
TUser? user;
switch (dto.Type.ToLower())
{
case null:
throw new KirelAuthenticationException($"User with login {login} is not found");
case { LockoutEnabled: true, LockoutEnd: not null } when DateTime.Now.ToUniversalTime() < user.LockoutEnd.Value.ToUniversalTime():
throw new KirelAuthenticationException($"User with login {login} is locked");
case "username":
user = await UserManager.FindByNameAsync(dto.Login);
break;
case "phone":
user = UserManager.Users.FirstOrDefault(u => u.PhoneNumber == dto.Login);
break;
case "email":
user = await UserManager.FindByEmailAsync(dto.Login);
break;
default:
throw new KirelAuthenticationException("Passed authentication type does not supported");
}
var result = await UserManager.CheckPasswordAsync(user, password);
if (user == null) throw new KirelAuthenticationException("User with passed login is not found");
var result = await UserManager.CheckPasswordAsync(user, dto.Password);
if (!result) throw new KirelAuthenticationException("Wrong password");
return user;
}
Expand Down
75 changes: 75 additions & 0 deletions src/Kirel.Identity.Core/Services/KirelSmsAuthenticationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Kirel.Identity.Core.Interfaces;
using Kirel.Identity.Core.Models;
using Kirel.Identity.Exceptions;
using Microsoft.AspNetCore.Identity;

namespace Kirel.Identity.Core.Services;

/// <summary>
/// Service for sms authentication
/// </summary>
public class KirelSmsAuthenticationService<TKey, TUser, TRole, TUserRole, TUserClaim,TRoleClaim>
where TKey : IComparable, IComparable<TKey>, IEquatable<TKey>
where TUser : KirelIdentityUser<TKey, TUser, TRole, TUserRole, TUserClaim, TRoleClaim>
where TRole : KirelIdentityRole<TKey, TRole, TUser, TUserRole, TRoleClaim, TUserClaim>
where TUserRole : KirelIdentityUserRole<TKey, TUserRole, TUser, TRole, TUserClaim, TRoleClaim>
where TUserClaim : IdentityUserClaim<TKey>
where TRoleClaim : IdentityRoleClaim<TKey>
{
/// <summary>
/// ISmsSender implementation
/// </summary>
protected readonly ISmsSender SmsSender;
/// <summary>
/// Identity user manager
/// </summary>
protected readonly UserManager<TUser> UserManager;

/// <summary>
/// Constructor for KirelSmsAuthenticationService
/// </summary>
/// <param name="userManager">Identity user manager</param>
/// <param name="smsSender">ISmsSender implementation</param>
public KirelSmsAuthenticationService(UserManager<TUser> userManager, ISmsSender smsSender)
{
UserManager = userManager;
SmsSender = smsSender;
}

/// <summary>
/// Login by phone number and single-use 4-digit code
/// </summary>
/// <param name="phoneNumber">User phone number</param>
/// <param name="code">Single-use 4-digit code</param>
/// <returns></returns>
public async Task<TUser> LoginByCode(string phoneNumber, string code)
{
var user = UserManager.Users.FirstOrDefault(u => u.PhoneNumber == phoneNumber);
if (user == null)
throw new KirelNotFoundException("User with given phone number was not found");
var numberConfirmed = await UserManager.IsPhoneNumberConfirmedAsync(user);
if (!numberConfirmed)
throw new KirelUnauthorizedException("Your phone number is not verified");
var result = await UserManager.VerifyUserTokenAsync(user, "Code provider", "PhoneAuthentication", code);
if (!result) throw new KirelUnauthorizedException("Invalid token");
return user;
}

/// <summary>
/// Sends single-use 4-digit code to the given phone number
/// </summary>
/// <param name="phoneNumber">User phone number</param>
public async Task SendCodeBySms(string phoneNumber)
{
var user = UserManager.Users.FirstOrDefault(u => u.PhoneNumber == phoneNumber);
if (user == null)
throw new KirelNotFoundException("User with given phone number was not found");

if (!user.PhoneNumberConfirmed)
throw new Exception("Your phone number is not verified");
var code = await UserManager.GenerateUserTokenAsync(user, "Code provider", "PhoneAuthentication");
var text = $"Please enter the following code on the login page: {code}";

await SmsSender.SendSms(text, phoneNumber);
}
}
78 changes: 78 additions & 0 deletions src/Kirel.Identity.Core/Services/KirelSmsConfirmationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using Kirel.Identity.Core.Interfaces;
using Kirel.Identity.Core.Models;
using Kirel.Identity.Exceptions;
using Microsoft.AspNetCore.Identity;

namespace Kirel.Identity.Core.Services;

/// <summary>
/// Service for user phone confirmation
/// </summary>
/// <typeparam name="TKey"> User key type </typeparam>
/// <typeparam name="TUser"> User type </typeparam>
/// <typeparam name="TRole"> The role entity type </typeparam>
/// <typeparam name="TUserRole"> The user role entity type </typeparam>
/// <typeparam name="TUserClaim"> User claim type</typeparam>
/// <typeparam name="TRoleClaim"> Role claim type</typeparam>
public class KirelSmsConfirmationService<TKey, TUser, TRole, TUserRole, TUserClaim, TRoleClaim>
where TKey : IComparable, IComparable<TKey>, IEquatable<TKey>
where TUser : KirelIdentityUser<TKey, TUser, TRole, TUserRole, TUserClaim, TRoleClaim>
where TRole : KirelIdentityRole<TKey, TRole, TUser, TUserRole, TRoleClaim, TUserClaim>
where TUserRole : KirelIdentityUserRole<TKey, TUserRole, TUser, TRole, TUserClaim, TRoleClaim>
where TUserClaim : IdentityUserClaim<TKey>
where TRoleClaim : IdentityRoleClaim<TKey>
{
/// <summary>
/// Identity user manager
/// </summary>
protected readonly UserManager<TUser> UserManager;
/// <summary>
/// Implementation of the ISmsSender interface
/// </summary>
protected readonly ISmsSender SmsSender;

/// <summary>
/// Constructor for KirelSmsConfirmationService
/// </summary>
/// <param name="userManager">Identity user manager</param>
/// <param name="smsSender">Implementation of the ISmsSender interface</param>
public KirelSmsConfirmationService(UserManager<TUser> userManager, ISmsSender smsSender)
{
UserManager = userManager;
SmsSender = smsSender;
}

/// <summary>
/// Sends confirmation code to the user non confirmed phone number
/// </summary>
/// <param name="user">Identity user</param>
/// <exception cref="KirelUnauthorizedException">If user phone number already confirmed</exception>
public async Task SendConfirmationSms(TUser user)
{
if (user.PhoneNumberConfirmed)
throw new KirelUnauthorizedException("Your phone number has already been confirmed");
var token = await UserManager.GenerateUserTokenAsync(user, "Code provider", "PhoneConfirmation");
var text = $"Please enter the following code on the login page: {token}";

await SmsSender.SendSms(text, user.PhoneNumber);
}

/// <summary>
/// Confirms user phone number by validating given code
/// </summary>
/// <param name="user">Identity user</param>
/// <param name="code">Confirmation code</param>
/// <exception cref="KirelUnauthorizedException">If given code is invalid</exception>
/// <exception cref="KirelIdentityStoreException">If identity store failed to update user</exception>
public async Task ConfirmPhoneNumber(TUser user, string code)
{
var codeValid = await UserManager.VerifyUserTokenAsync(user, "Code provider", "PhoneConfirmation", code);
if (!codeValid)
throw new KirelUnauthorizedException("Invalid code");

user.PhoneNumberConfirmed = true;
var result = await UserManager.UpdateAsync(user);
if (!result.Succeeded)
throw new KirelIdentityStoreException("Failed to update user field 'PhoneNumberConfirmed'");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using FluentValidation;
using Kirel.Identity.Core.Models;
using Kirel.Identity.DTOs;
using Microsoft.AspNetCore.Identity;

namespace Kirel.Identity.Core.Validators;

/// <summary>
/// Validation for KirelUserAuthenticationDtoValidator
/// </summary>
public class KirelUserAuthenticationDtoValidator<TKey, TUser, TRole, TUserRole, TUserAuthenticationDto, TUserClaim, TRoleClaim> : AbstractValidator<TUserAuthenticationDto>
where TKey : IComparable, IComparable<TKey>, IEquatable<TKey>
where TUser : KirelIdentityUser<TKey, TUser, TRole, TUserRole, TUserClaim, TRoleClaim>
where TRole : KirelIdentityRole<TKey, TRole, TUser, TUserRole, TRoleClaim, TUserClaim>
where TUserRole : KirelIdentityUserRole<TKey, TUserRole, TUser, TRole, TUserClaim, TRoleClaim>
where TUserAuthenticationDto : KirelUserAuthenticationDto
where TUserClaim : IdentityUserClaim<TKey>
where TRoleClaim : IdentityRoleClaim<TKey>
{
private readonly UserManager<TUser> _userManager;

/// <summary>
/// Constructor for KirelUserAuthenticationDtoValidator
/// </summary>
/// <param name="userManager">Identity user manager</param>
public KirelUserAuthenticationDtoValidator(UserManager<TUser> userManager)
{
_userManager = userManager;

var message = "";
var typeValues = new List<string>{ "username", "phone", "email" };
RuleFor(d => d.Type.ToLower())
.Must(type => typeValues.Contains(type))
.WithMessage($"Authentication type can only be one of the following: {string.Join(",", typeValues)}");
When(d => d.Type.ToLower() == "username", () =>
{
RuleFor(d => d.Login)
.MinimumLength(4).WithMessage("The username must be at least 4 characters long.")
.Matches(@"^(?=.*[a-zA-Z]{1,})(?=.*[\d]{0,})[a-zA-Z0-9.]{4,20}$")
.WithMessage("Username can only contains letters, numbers and dots");
});
When(d => d.Type.ToLower() == "phone", () =>
{
RuleFor(d => d.Login)
.Matches("^[1-9][0-9]{10,12}$")
.WithMessage("The phone number must be in international format: 11 to 13 digits without a plus")
.Must((dto, _) => PhoneConfirmed(dto.Login, out message))
.WithMessage(_ => message);
});
When(d => d.Type.ToLower() == "email", () =>
{
RuleFor(d => d.Login)
.EmailAddress().WithMessage("The email address is invalid")
.Must((dto, _) => EmailConfirmed(dto.Login, out message))
.WithMessage(_ => message);
});
}

private bool PhoneConfirmed(string phone, out string message)
{
message = "";
var user = _userManager.Users.FirstOrDefault(u => u.PhoneNumber == phone);
if (user == null) return false;
if (!user.PhoneNumberConfirmed)
message = "To use phone authentication, you must confirm your phone number";
return user.PhoneNumberConfirmed;
}

private bool EmailConfirmed(string email, out string message)
{
message = "";
var user = _userManager.Users.FirstOrDefault(u => u.Email == email);
if (user == null) return false;
if (!user.EmailConfirmed)
message = "To use email authentication, you must confirm your email";
return user.EmailConfirmed;
}
}
21 changes: 21 additions & 0 deletions src/Kirel.Identity.DTOs/KirelUserAuthenticationDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace Kirel.Identity.DTOs;

/// <summary>
/// Dto for user authentication
/// </summary>
public class KirelUserAuthenticationDto
{
/// <summary>
/// Login type can be one of the following: 'Username', 'Phone' or 'Email'.
/// </summary>
public string Type { get; set; } = "";
/// <summary>
/// User login can be one of the following: Username, Phone number or Email.
/// For authentication by number or email those must be confirmed
/// </summary>
public string Login { get; set; } = "";
/// <summary>
/// User password or token from phone/email based on authentication type
/// </summary>
public string Password { get; set; } = "";
}
Loading

0 comments on commit 91793a0

Please sign in to comment.