diff --git a/Tsi1.Api/Tsi1.Api/Controllers/UserController.cs b/Tsi1.Api/Tsi1.Api/Controllers/UserController.cs index 1fbbe5ca1f7cdb42755c0717dbfc42a9035983aa..e38a6b37106c031c4edc5f14d9544d7b54d0e8aa 100644 --- a/Tsi1.Api/Tsi1.Api/Controllers/UserController.cs +++ b/Tsi1.Api/Tsi1.Api/Controllers/UserController.cs @@ -23,21 +23,27 @@ namespace Tsi1.Api.Controllers private readonly IUserService _userService; private readonly IUserTypeService _userTypeService; private readonly ITenantService _tenantService; - - public UserController(IJwtAuthManager jwtAuthManager, IUserService userService, - IUserTypeService userTypeService, ITenantService tenantService) + private readonly IEmailService _emailService; + + public UserController( + IJwtAuthManager jwtAuthManager, + IUserService userService, + IUserTypeService userTypeService, + ITenantService tenantService, + IEmailService emailService) { _jwtAuthManager = jwtAuthManager; _userService = userService; _userTypeService = userTypeService; _tenantService = tenantService; + _emailService = emailService; } [AllowAnonymous] [HttpPost("Login")] public async Task<IActionResult> Login(LoginRequest request) { - var resultSplit = request.UserName.Split("@"); + var resultSplit = request.Username.Split("@"); if (resultSplit.Count() != 2) { @@ -45,9 +51,7 @@ namespace Tsi1.Api.Controllers } var userName = resultSplit[0]; - var tenantName = resultSplit[1]; - var tenantId = await _tenantService.GetByName(tenantName); if (tenantId.HasError) @@ -56,7 +60,6 @@ namespace Tsi1.Api.Controllers } var result = await _userService.Authenticate(userName, request.Password, tenantId.Data); - if (result.HasError) { return BadRequest(result.Message); @@ -217,5 +220,108 @@ namespace Tsi1.Api.Controllers return Ok(result.Data); } + [AllowAnonymous] + [HttpGet("ForgotPassword/{username}")] + public async Task<IActionResult> ForgotPassword(string username) + { + var resultSplit = username.Split("@"); + + if (resultSplit.Count() != 2) + { + return BadRequest(ErrorMessages.InvalidUsername); + } + + username = resultSplit[0]; + var tenantName = resultSplit[1]; + var tenantId = await _tenantService.GetByName(tenantName); + + if (tenantId.HasError) + { + return BadRequest(tenantId.Message); + } + + var userResult = await _userService.GetByUsername(username, tenantId.Data); + + if (userResult.HasError) + { + return BadRequest(userResult.Message); + } + + var code = _jwtAuthManager.GenerateVerificationCode(username, DateTime.Now); + + var result = await _emailService.SendVerificationCode(userResult.Data.Email, code); + if (result.HasError) + { + return BadRequest("Ha ocurrido un error"); + } + + return Ok(); + } + + [AllowAnonymous] + [HttpGet("VerificationCode/{username}/{code}")] + public async Task<IActionResult> VerificationCode(string username, int code) + { + var resultSplit = username.Split("@"); + + if (resultSplit.Count() != 2) + { + return BadRequest(ErrorMessages.InvalidUsername); + } + + username = resultSplit[0]; + var tenantName = resultSplit[1]; + var tenantId = await _tenantService.GetByName(tenantName); + + if (tenantId.HasError) + { + return BadRequest(tenantId.Message); + } + + if (!_jwtAuthManager.ValidateVerificationCode(username, code)) + { + return BadRequest("Código de verificación incorrecto"); + } + + var userResult = await _userService.GetByUsername(username, tenantId.Data); + if (userResult.HasError) + { + return BadRequest(userResult.Message); + } + + var user = userResult.Data; + var claims = new[] + { + new Claim("Id", user.Id.ToString()), + new Claim("Username", user.Username), + new Claim("TenantId", user.TenantId.ToString()), + new Claim(ClaimTypes.Role, user.UserType.Name) + }; + + var jwtResult = _jwtAuthManager.GenerateTokens(user.Username, claims, DateTime.Now); + + return Ok(new LoginResult + { + Id = user.Id, + UserName = user.Username, + Role = user.UserType.Name, + AccessToken = jwtResult.AccessToken, + RefreshToken = jwtResult.RefreshToken.TokenString + }); + } + + [HttpPost("RestorePassword")] + public async Task<IActionResult> RestorePassword(RestorePasswordDto dto) + { + var userId = int.Parse(HttpContext.User.Claims.FirstOrDefault(x => x.Type == "Id").Value); + + var result = await _userService.UpdatePassword(userId, dto.Password); + if (result.HasError) + { + return BadRequest(result.Message); + } + + return Ok(); + } } } diff --git a/Tsi1.Api/Tsi1.Api/Infrastructure/IJwtAuthManager.cs b/Tsi1.Api/Tsi1.Api/Infrastructure/IJwtAuthManager.cs index e9342c6dbb7d95fbf866b95abdd4a4fe6142e254..ab8bf680e9aa66084d608abaa9cc6b9a57c182ee 100644 --- a/Tsi1.Api/Tsi1.Api/Infrastructure/IJwtAuthManager.cs +++ b/Tsi1.Api/Tsi1.Api/Infrastructure/IJwtAuthManager.cs @@ -14,5 +14,8 @@ namespace Tsi1.Api.Infrastructure void RemoveExpiredRefreshTokens(DateTime now); void RemoveRefreshTokenByUserName(string userName); (ClaimsPrincipal, JwtSecurityToken) DecodeJwtToken(string token); + int GenerateVerificationCode(string username, DateTime now); + bool ValidateVerificationCode(string username, int code); + public void RemoveExpiredVerificationCodes(DateTime now); } } diff --git a/Tsi1.Api/Tsi1.Api/Infrastructure/JwtAuthManager.cs b/Tsi1.Api/Tsi1.Api/Infrastructure/JwtAuthManager.cs index 672247e7bf45a0cd68789cd77290da0f2f2bf606..f1bf057d038a817c83884e80eb99bece1e185bf2 100644 --- a/Tsi1.Api/Tsi1.Api/Infrastructure/JwtAuthManager.cs +++ b/Tsi1.Api/Tsi1.Api/Infrastructure/JwtAuthManager.cs @@ -15,6 +15,7 @@ namespace Tsi1.Api.Infrastructure { public IImmutableDictionary<string, RefreshToken> UsersRefreshTokensReadOnlyDictionary => _usersRefreshTokens.ToImmutableDictionary(); private readonly ConcurrentDictionary<string, RefreshToken> _usersRefreshTokens; // can store in a database or a distributed cache + private readonly ConcurrentDictionary<string, VerificationCode> _usersVerificationCodes; private readonly JwtTokenConfig _jwtTokenConfig; private readonly byte[] _secret; @@ -22,6 +23,7 @@ namespace Tsi1.Api.Infrastructure { _jwtTokenConfig = jwtTokenConfig; _usersRefreshTokens = new ConcurrentDictionary<string, RefreshToken>(); + _usersVerificationCodes = new ConcurrentDictionary<string, VerificationCode>(); _secret = Encoding.ASCII.GetBytes(jwtTokenConfig.Secret); } @@ -118,6 +120,36 @@ namespace Tsi1.Api.Infrastructure return (principal, validatedToken as JwtSecurityToken); } + public int GenerateVerificationCode(string username, DateTime now) + { + var code = new Random().Next(1000000); + + var verficationCode = new VerificationCode() + { + Username = username, + Code = code, + ExpireAt = now.AddMinutes(_jwtTokenConfig.VerificationCodeExpiration) + }; + + _usersVerificationCodes.AddOrUpdate(username, verficationCode, (s, t) => verficationCode); + + return code; + } + + public bool ValidateVerificationCode(string username, int code) + { + return _usersVerificationCodes.TryGetValue(username, out _); + } + + public void RemoveExpiredVerificationCodes(DateTime now) + { + var expiredCodes = _usersVerificationCodes.Where(x => x.Value.ExpireAt < now).ToList(); + foreach (var expiredCode in expiredCodes) + { + _usersVerificationCodes.TryRemove(expiredCode.Key, out _); + } + } + private static string GenerateRefreshTokenString() { var randomNumber = new byte[32]; diff --git a/Tsi1.Api/Tsi1.Api/Infrastructure/JwtTokenConfig.cs b/Tsi1.Api/Tsi1.Api/Infrastructure/JwtTokenConfig.cs index 854d596cf716e1388a8c507ba3c9426b77031016..5eacf5d9d79ecab406e690f602e7a8789c21c357 100644 --- a/Tsi1.Api/Tsi1.Api/Infrastructure/JwtTokenConfig.cs +++ b/Tsi1.Api/Tsi1.Api/Infrastructure/JwtTokenConfig.cs @@ -4,19 +4,11 @@ namespace Tsi1.Api.Infrastructure { public class JwtTokenConfig { - [JsonPropertyName("secret")] public string Secret { get; set; } - - [JsonPropertyName("issuer")] public string Issuer { get; set; } - - [JsonPropertyName("audience")] public string Audience { get; set; } - - [JsonPropertyName("accessTokenExpiration")] public int AccessTokenExpiration { get; set; } - - [JsonPropertyName("refreshTokenExpiration")] public int RefreshTokenExpiration { get; set; } + public int VerificationCodeExpiration { get; set; } } } diff --git a/Tsi1.Api/Tsi1.Api/Infrastructure/JwtVerificationCodeCache.cs b/Tsi1.Api/Tsi1.Api/Infrastructure/JwtVerificationCodeCache.cs new file mode 100644 index 0000000000000000000000000000000000000000..7805cedf35a8d75c22f860acd5f849ecf7d0a4fb --- /dev/null +++ b/Tsi1.Api/Tsi1.Api/Infrastructure/JwtVerificationCodeCache.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Hosting; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Tsi1.Api.Infrastructure +{ + public class JwtVerificationCodeCache : IHostedService, IDisposable + { + private Timer _timer; + private readonly IJwtAuthManager _jwtAuthManager; + + public JwtVerificationCodeCache(IJwtAuthManager jwtAuthManager) + { + _jwtAuthManager = jwtAuthManager; + } + + public Task StartAsync(CancellationToken stoppingToken) + { + // remove expired refresh tokens from cache every minute + _timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromMinutes(1)); + return Task.CompletedTask; + } + + private void DoWork(object state) + { + _jwtAuthManager.RemoveExpiredVerificationCodes(DateTime.Now); + } + + public Task StopAsync(CancellationToken stoppingToken) + { + _timer?.Change(Timeout.Infinite, 0); + return Task.CompletedTask; + } + + public void Dispose() + { + _timer?.Dispose(); + } + } +} diff --git a/Tsi1.Api/Tsi1.Api/Models/LoginRequest.cs b/Tsi1.Api/Tsi1.Api/Models/LoginRequest.cs index 96e87b18d03e7185430f760fb0a5c9caefe6611f..5408e8b422bb74b8ad6257bacefdc6aedef08259 100644 --- a/Tsi1.Api/Tsi1.Api/Models/LoginRequest.cs +++ b/Tsi1.Api/Tsi1.Api/Models/LoginRequest.cs @@ -11,7 +11,7 @@ namespace Tsi1.Api.Models { [Required] [JsonPropertyName("username")] - public string UserName { get; set; } + public string Username { get; set; } [Required] [JsonPropertyName("password")] diff --git a/Tsi1.Api/Tsi1.Api/Models/VerificationCode.cs b/Tsi1.Api/Tsi1.Api/Models/VerificationCode.cs new file mode 100644 index 0000000000000000000000000000000000000000..253d88390adeeadd50843b579cc03d3e5372d153 --- /dev/null +++ b/Tsi1.Api/Tsi1.Api/Models/VerificationCode.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Tsi1.Api.Models +{ + public class VerificationCode + { + public string Username { get; set; } + public int Code { get; set; } + public DateTime ExpireAt { get; set; } + } +} diff --git a/Tsi1.Api/Tsi1.Api/Startup.cs b/Tsi1.Api/Tsi1.Api/Startup.cs index 729f1a70cfe93cef91f9ab928d25676c97eaf073..8dd99046a3ee149eaf1ba5a977b024899657c536 100644 --- a/Tsi1.Api/Tsi1.Api/Startup.cs +++ b/Tsi1.Api/Tsi1.Api/Startup.cs @@ -45,6 +45,8 @@ namespace Tsi1.Api var postgreSqlSection = isElasticCloud ? "PostgreSqlCloud" : "PostgreSql"; var mongoDbSection = isElasticCloud ? "Tsi1DatabaseSettingsCloud" : "Tsi1DatabaseSettings"; + var jwtTokenConfig = Configuration.GetSection("jwtTokenConfig").Get<JwtTokenConfig>(); + services.AddDbContext<Tsi1Context>(x => x.UseNpgsql(Configuration.GetConnectionString(postgreSqlSection))); services.Configure<Tsi1DatabaseSettings>( @@ -53,6 +55,13 @@ namespace Tsi1.Api services.AddSingleton<ITsi1DatabaseSettings>(sp => sp.GetRequiredService<IOptions<Tsi1DatabaseSettings>>().Value); + services.AddSingleton(jwtTokenConfig); + + services.AddSingleton<IJwtAuthManager, JwtAuthManager>(); + services.AddHostedService<JwtRefreshTokenCache>(); + services.AddHostedService<JwtVerificationCodeCache>(); + + services.AddSingleton<IMessageService, MessageService>(); services.AddScoped<IUserService, UserService>(); @@ -67,10 +76,7 @@ namespace Tsi1.Api services.AddScoped<IEmailService, EmailService>(); services.AddCors(); - - var jwtTokenConfig = Configuration.GetSection("jwtTokenConfig").Get<JwtTokenConfig>(); - services.AddSingleton(jwtTokenConfig); - + services.AddAuthentication(x => { x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -92,9 +98,6 @@ namespace Tsi1.Api }; }); - services.AddSingleton<IJwtAuthManager, JwtAuthManager>(); - services.AddHostedService<JwtRefreshTokenCache>(); - services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "Tsi1 api", Version = "v1" }); @@ -125,7 +128,7 @@ namespace Tsi1.Api mc.AddProfile(new MappingProfile()); }); - IMapper mapper = mappingConfig.CreateMapper(); + var mapper = mappingConfig.CreateMapper(); services.AddSingleton(mapper); } diff --git a/Tsi1.Api/Tsi1.Api/appsettings.json b/Tsi1.Api/Tsi1.Api/appsettings.json index 45af97fda74df094689e136d9938c4e8affeab61..810e99c8b712f19dba9b1aa60a3397e8533f5881 100644 --- a/Tsi1.Api/Tsi1.Api/appsettings.json +++ b/Tsi1.Api/Tsi1.Api/appsettings.json @@ -14,12 +14,13 @@ "ConnectionString": "mongodb://mongo:27017", "DatabaseName": "Tsi1Db" }, - "jwtTokenConfig": { - "secret": "1234567890123456789", - "issuer": "https://localhost:5000", - "audience": "https://localhost:5000", - "accessTokenExpiration": 20, - "refreshTokenExpiration": 60 + "JwtTokenConfig": { + "Secret": "1234567890123456789", + "Issuer": "https://localhost:5000", + "Audience": "https://localhost:5000", + "AccessTokenExpiration": 20, + "RefreshTokenExpiration": 60, + "VerificationCodeExpiration": 5 }, "MailSettings": { "Mail": "tsi1.grupo2.2020@gmail.com", diff --git a/Tsi1.Api/Tsi1.BusinessLayer/Dtos/RestorePasswordDto.cs b/Tsi1.Api/Tsi1.BusinessLayer/Dtos/RestorePasswordDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..b1e4c4d35789e8edc7413f1e4fe3e8eb245e4dfe --- /dev/null +++ b/Tsi1.Api/Tsi1.BusinessLayer/Dtos/RestorePasswordDto.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Tsi1.BusinessLayer.Dtos +{ + public class RestorePasswordDto + { + public string Password { get; set; } + } +} diff --git a/Tsi1.Api/Tsi1.BusinessLayer/Interfaces/IEmailService.cs b/Tsi1.Api/Tsi1.BusinessLayer/Interfaces/IEmailService.cs index a97ea5c312cf5448689c731402cf293ea49ebca4..3cafba23552d1fb40adb046cebae313398b60098 100644 --- a/Tsi1.Api/Tsi1.BusinessLayer/Interfaces/IEmailService.cs +++ b/Tsi1.Api/Tsi1.BusinessLayer/Interfaces/IEmailService.cs @@ -14,5 +14,7 @@ namespace Tsi1.BusinessLayer.Interfaces Task<ServiceResult<bool>> SendEmailAsync(MimeMessage message); Task<ServiceResult<bool>> NotifyNewPostOrMessage(PostCreateDto postCreateDto, List<string> mails); + + public Task<ServiceResult<bool>> SendVerificationCode(string mail, int code); } } diff --git a/Tsi1.Api/Tsi1.BusinessLayer/Interfaces/IUserService.cs b/Tsi1.Api/Tsi1.BusinessLayer/Interfaces/IUserService.cs index 12444e66d7fe3e492bbcbdb8b0d7353730c89beb..cc1a12076c6cf6467484ac7fecdb336dee221980 100644 --- a/Tsi1.Api/Tsi1.BusinessLayer/Interfaces/IUserService.cs +++ b/Tsi1.Api/Tsi1.BusinessLayer/Interfaces/IUserService.cs @@ -17,5 +17,9 @@ namespace Tsi1.BusinessLayer.Interfaces Task<ServiceResult<List<UserPreviewDto>>> GetAll(int tenantId); Task<ServiceResult<UserPreviewDto>> GetById(int userId); + + Task<ServiceResult<User>> GetByUsername(string username, int tenantId); + + Task<ServiceResult<bool>> UpdatePassword(int userId, string password); } } diff --git a/Tsi1.Api/Tsi1.BusinessLayer/Services/EmailService.cs b/Tsi1.Api/Tsi1.BusinessLayer/Services/EmailService.cs index d00e398d81ef6e05d34547d90d87d5a6e11f7ffd..9519fc5b0cf3037535e101a866a297392b786825 100644 --- a/Tsi1.Api/Tsi1.BusinessLayer/Services/EmailService.cs +++ b/Tsi1.Api/Tsi1.BusinessLayer/Services/EmailService.cs @@ -86,9 +86,22 @@ namespace Tsi1.BusinessLayer.Services }; var result = await SendEmailAsync(message); - return result; } + public async Task<ServiceResult<bool>> SendVerificationCode(string mail, int code) + { + var message = new MimeMessage(); + + message.To.Add(MailboxAddress.Parse(mail)); + message.Subject = "Código de verificación"; + message.Body = new TextPart("html") + { + Text = $"<p>Su código de verificación es {code}</p>" + }; + + var result = await SendEmailAsync(message); + return result; + } } } diff --git a/Tsi1.Api/Tsi1.BusinessLayer/Services/UserService.cs b/Tsi1.Api/Tsi1.BusinessLayer/Services/UserService.cs index 35b14f29c797130271be0d4adacb9190d0ffbc70..a32af3a601986d0160ff4fec651a6b9510a62e62 100644 --- a/Tsi1.Api/Tsi1.BusinessLayer/Services/UserService.cs +++ b/Tsi1.Api/Tsi1.BusinessLayer/Services/UserService.cs @@ -138,5 +138,61 @@ namespace Tsi1.BusinessLayer.Services return result; } + + public async Task<ServiceResult<User>> GetByUsername(string username, int tenantId) + { + var result = new ServiceResult<User>(); + + var user = await _context.Users + .Include(x => x.UserType) + .Include(x => x.Student) + .Include(x => x.Professor) + .Where(x => x.TenantId == tenantId + && x.Username == username) + .FirstOrDefaultAsync(); + + if (user == null) + { + result.HasError = true; + result.Message = string.Format(ErrorMessages.UserDoesNotExist, username); + return result; + } + + var userType = user.UserType.Name; + + if (userType == UserTypes.Student && user.Student == null) + { + result.HasError = true; + result.Message = string.Format(ErrorMessages.StudentDoesNotExist, username); + return result; + } + else if (userType == UserTypes.Professor && user.Professor == null) + { + result.HasError = true; + result.Message = string.Format(ErrorMessages.ProffesorDoesNotExist, username); + return result; + } + + result.Data = user; + return result; + } + + public async Task<ServiceResult<bool>> UpdatePassword(int userId, string password) + { + var result = new ServiceResult<bool>(); + var user = await _context.Users.FirstOrDefaultAsync(); + + if (user == null) + { + result.HasError = true; + result.Message = string.Format(ErrorMessages.UserDoesNotExist, userId); + return result; + } + + user.Password = password; + await _context.SaveChangesAsync(); + + return result; + } } }