From dcc2e99bb199c1758aa631611c5ccbe23fa83b5f Mon Sep 17 00:00:00 2001 From: Emmanuel Rojas Date: Fri, 4 Sep 2026 12:35:11 -0500 Subject: [PATCH] Feature(Auth): Add Login and Refresh Token --- RT.API/Controllers/AuthController.cs | 31 ++++++++ RT.API/Program.cs | 35 +++++++++ RT.API/RT.API.csproj | 1 + RT.API/appsettings.json | 9 ++- RT.Application/Class1.cs | 6 -- RT.Application/DependencyInjection.cs | 4 ++ RT.Application/RT.Application.csproj | 1 + .../Services/Contract/IAuthService.cs | 10 +++ .../Services/Contract/ITokenService.cs | 7 ++ .../Services/Implementation/AuthService.cs | 71 +++++++++++++++++++ .../Services/Implementation/TokenService.cs | 43 +++++++++++ RT.Domain/Class1.cs | 6 -- RT.Domain/DTOs/Auth/LoginRequestDto.cs | 3 + RT.Domain/DTOs/Auth/LoginResponseDto.cs | 8 +++ RT.Domain/DTOs/Auth/RefreshRequestDto.cs | 3 + RT.Domain/Entities/RefreshToken.cs | 10 +++ RT.Infrastructure/Class1.cs | 6 -- RT.Infrastructure/Data/AppDbContext.cs | 8 ++- RT.Infrastructure/Identity/ApplicationUser.cs | 8 +++ RT.Infrastructure/RT.Infrastructure.csproj | 2 + 20 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 RT.API/Controllers/AuthController.cs delete mode 100644 RT.Application/Class1.cs create mode 100644 RT.Application/Services/Contract/IAuthService.cs create mode 100644 RT.Application/Services/Contract/ITokenService.cs create mode 100644 RT.Application/Services/Implementation/AuthService.cs create mode 100644 RT.Application/Services/Implementation/TokenService.cs delete mode 100644 RT.Domain/Class1.cs create mode 100644 RT.Domain/DTOs/Auth/LoginRequestDto.cs create mode 100644 RT.Domain/DTOs/Auth/LoginResponseDto.cs create mode 100644 RT.Domain/DTOs/Auth/RefreshRequestDto.cs create mode 100644 RT.Domain/Entities/RefreshToken.cs delete mode 100644 RT.Infrastructure/Class1.cs create mode 100644 RT.Infrastructure/Identity/ApplicationUser.cs diff --git a/RT.API/Controllers/AuthController.cs b/RT.API/Controllers/AuthController.cs new file mode 100644 index 0000000..e118375 --- /dev/null +++ b/RT.API/Controllers/AuthController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; +using RT.Application.Services.Contract; +using RT.Domain.DTOs.Auth; + +namespace RT.API.Controllers; + +[ApiController] +[Route("api/auth")] +public class AuthController(IAuthService authService) : ControllerBase +{ + [HttpPost("login")] + public async Task Login([FromBody] LoginRequestDto dto, CancellationToken ct) + { + var result = await authService.LoginAsync(dto, ct); + return Ok(result); + } + + [HttpPost("refresh")] + public async Task Refresh([FromBody] RefreshRequestDto dto, CancellationToken ct) + { + var result = await authService.RefreshAsync(dto, ct); + return Ok(result); + } + + [HttpPost("revoke")] + public async Task Revoke([FromBody] RefreshRequestDto dto, CancellationToken ct) + { + await authService.RevokeAsync(dto, ct); + return NoContent(); + } +} diff --git a/RT.API/Program.cs b/RT.API/Program.cs index c03eb3b..ada949e 100644 --- a/RT.API/Program.cs +++ b/RT.API/Program.cs @@ -1,5 +1,11 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; using RT.Application; using RT.Infrastructure; +using RT.Infrastructure.Data; +using RT.Infrastructure.Identity; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -9,6 +15,34 @@ builder.Services.AddOpenApi(); builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddIdentity(options => + { + options.Password.RequireDigit = true; + options.Password.RequiredLength = 8; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + var jwt = builder.Configuration.GetSection("Jwt"); + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwt["Issuer"], + ValidAudience = jwt["Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["Key"]!)), + }; + }); + +builder.Services.AddAuthorization(); + var app = builder.Build(); if (app.Environment.IsDevelopment()) @@ -18,6 +52,7 @@ if (app.Environment.IsDevelopment()) } app.UseHttpsRedirection(); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/RT.API/RT.API.csproj b/RT.API/RT.API.csproj index 846ef7f..2f12748 100644 --- a/RT.API/RT.API.csproj +++ b/RT.API/RT.API.csproj @@ -6,6 +6,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/RT.API/appsettings.json b/RT.API/appsettings.json index d8b8806..aa0e1f3 100644 --- a/RT.API/appsettings.json +++ b/RT.API/appsettings.json @@ -8,5 +8,12 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Jwt": { + "Key": "CHANGE_THIS_SECRET_KEY_MIN_32_CHARS!!", + "Issuer": "RynextTemplate", + "Audience": "RynextTemplate", + "ExpiresMinutes": "60", + "RefreshExpiresDays": "7" + } } diff --git a/RT.Application/Class1.cs b/RT.Application/Class1.cs deleted file mode 100644 index 4a42cb0..0000000 --- a/RT.Application/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace RT.Application; - -public class Class1 -{ - -} diff --git a/RT.Application/DependencyInjection.cs b/RT.Application/DependencyInjection.cs index 11c8714..128f45f 100644 --- a/RT.Application/DependencyInjection.cs +++ b/RT.Application/DependencyInjection.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using RT.Application.Services.Contract; +using RT.Application.Services.Implementation; namespace RT.Application; @@ -7,6 +9,8 @@ public static class DependencyInjection public static IServiceCollection AddApplication(this IServiceCollection services) { services.AddAutoMapper(cfg => cfg.AddMaps(typeof(DependencyInjection).Assembly)); + services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/RT.Application/RT.Application.csproj b/RT.Application/RT.Application.csproj index 00a7ff2..b5b6adf 100644 --- a/RT.Application/RT.Application.csproj +++ b/RT.Application/RT.Application.csproj @@ -7,6 +7,7 @@ + diff --git a/RT.Application/Services/Contract/IAuthService.cs b/RT.Application/Services/Contract/IAuthService.cs new file mode 100644 index 0000000..f2b1a61 --- /dev/null +++ b/RT.Application/Services/Contract/IAuthService.cs @@ -0,0 +1,10 @@ +using RT.Domain.DTOs.Auth; + +namespace RT.Application.Services.Contract; + +public interface IAuthService +{ + Task LoginAsync(LoginRequestDto dto, CancellationToken ct = default); + Task RefreshAsync(RefreshRequestDto dto, CancellationToken ct = default); + Task RevokeAsync(RefreshRequestDto dto, CancellationToken ct = default); +} diff --git a/RT.Application/Services/Contract/ITokenService.cs b/RT.Application/Services/Contract/ITokenService.cs new file mode 100644 index 0000000..1d19dd3 --- /dev/null +++ b/RT.Application/Services/Contract/ITokenService.cs @@ -0,0 +1,7 @@ +namespace RT.Application.Services.Contract; + +public interface ITokenService +{ + (string token, DateTime expiresAt) GenerateToken(string userId, string email, string userName); + (string token, DateTime expiresAt) GenerateRefreshToken(); +} diff --git a/RT.Application/Services/Implementation/AuthService.cs b/RT.Application/Services/Implementation/AuthService.cs new file mode 100644 index 0000000..1dd8d47 --- /dev/null +++ b/RT.Application/Services/Implementation/AuthService.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using RT.Application.Services.Contract; +using RT.Domain.DTOs.Auth; +using RT.Domain.Entities; +using RT.Infrastructure.Data; +using RT.Infrastructure.Identity; + +namespace RT.Application.Services.Implementation; + +public class AuthService( + UserManager userManager, + ITokenService tokenService, + AppDbContext db) : IAuthService +{ + public async Task LoginAsync(LoginRequestDto dto, CancellationToken ct = default) + { + var user = await userManager.FindByEmailAsync(dto.Email) + ?? throw new UnauthorizedAccessException("Invalid credentials."); + + if (!await userManager.CheckPasswordAsync(user, dto.Password)) + throw new UnauthorizedAccessException("Invalid credentials."); + + return await BuildResponseAsync(user, ct); + } + + public async Task RefreshAsync(RefreshRequestDto dto, CancellationToken ct = default) + { + var stored = await db.RefreshTokens.FirstOrDefaultAsync(r => r.Token == dto.RefreshToken, ct) + ?? throw new UnauthorizedAccessException("Invalid refresh token."); + + if (stored.IsRevoked) + throw new UnauthorizedAccessException("Refresh token revoked."); + + if (stored.ExpiresAt < DateTime.UtcNow) + throw new UnauthorizedAccessException("Refresh token expired."); + + stored.IsRevoked = true; + await db.SaveChangesAsync(ct); + + var user = await userManager.FindByIdAsync(stored.UserId) + ?? throw new UnauthorizedAccessException("User not found."); + + return await BuildResponseAsync(user, ct); + } + + public async Task RevokeAsync(RefreshRequestDto dto, CancellationToken ct = default) + { + var stored = await db.RefreshTokens.FirstOrDefaultAsync(r => r.Token == dto.RefreshToken, ct); + if (stored is null || stored.IsRevoked) return; + + stored.IsRevoked = true; + await db.SaveChangesAsync(ct); + } + + private async Task BuildResponseAsync(ApplicationUser user, CancellationToken ct) + { + var (token, expiresAt) = tokenService.GenerateToken(user.Id, user.Email!, user.UserName!); + var (refreshToken, refreshExpiresAt) = tokenService.GenerateRefreshToken(); + + db.RefreshTokens.Add(new RefreshToken + { + UserId = user.Id, + Token = refreshToken, + ExpiresAt = refreshExpiresAt, + }); + await db.SaveChangesAsync(ct); + + return new LoginResponseDto(token, expiresAt, refreshToken, refreshExpiresAt, user.MustChangePassword); + } +} diff --git a/RT.Application/Services/Implementation/TokenService.cs b/RT.Application/Services/Implementation/TokenService.cs new file mode 100644 index 0000000..5e5dfb5 --- /dev/null +++ b/RT.Application/Services/Implementation/TokenService.cs @@ -0,0 +1,43 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using RT.Application.Services.Contract; + +namespace RT.Application.Services.Implementation; + +public class TokenService(IConfiguration configuration) : ITokenService +{ + public (string token, DateTime expiresAt) GenerateToken(string userId, string email, string userName) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"]!)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var expires = DateTime.UtcNow.AddMinutes(double.Parse(configuration["Jwt:ExpiresMinutes"] ?? "60")); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, userId), + new Claim(JwtRegisteredClaimNames.Email, email), + new Claim(JwtRegisteredClaimNames.Name, userName), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var token = new JwtSecurityToken( + issuer: configuration["Jwt:Issuer"], + audience: configuration["Jwt:Audience"], + claims: claims, + expires: expires, + signingCredentials: creds); + + return (new JwtSecurityTokenHandler().WriteToken(token), expires); + } + + public (string token, DateTime expiresAt) GenerateRefreshToken() + { + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + var expires = DateTime.UtcNow.AddDays(double.Parse(configuration["Jwt:RefreshExpiresDays"] ?? "7")); + return (token, expires); + } +} diff --git a/RT.Domain/Class1.cs b/RT.Domain/Class1.cs deleted file mode 100644 index 699f424..0000000 --- a/RT.Domain/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace RT.Domain; - -public class Class1 -{ - -} diff --git a/RT.Domain/DTOs/Auth/LoginRequestDto.cs b/RT.Domain/DTOs/Auth/LoginRequestDto.cs new file mode 100644 index 0000000..2718ce3 --- /dev/null +++ b/RT.Domain/DTOs/Auth/LoginRequestDto.cs @@ -0,0 +1,3 @@ +namespace RT.Domain.DTOs.Auth; + +public record LoginRequestDto(string Email, string Password); diff --git a/RT.Domain/DTOs/Auth/LoginResponseDto.cs b/RT.Domain/DTOs/Auth/LoginResponseDto.cs new file mode 100644 index 0000000..8ce0f15 --- /dev/null +++ b/RT.Domain/DTOs/Auth/LoginResponseDto.cs @@ -0,0 +1,8 @@ +namespace RT.Domain.DTOs.Auth; + +public record LoginResponseDto( + string Token, + DateTime ExpiresAt, + string RefreshToken, + DateTime RefreshExpiresAt, + bool MustChangePassword); diff --git a/RT.Domain/DTOs/Auth/RefreshRequestDto.cs b/RT.Domain/DTOs/Auth/RefreshRequestDto.cs new file mode 100644 index 0000000..cefb147 --- /dev/null +++ b/RT.Domain/DTOs/Auth/RefreshRequestDto.cs @@ -0,0 +1,3 @@ +namespace RT.Domain.DTOs.Auth; + +public record RefreshRequestDto(string RefreshToken); diff --git a/RT.Domain/Entities/RefreshToken.cs b/RT.Domain/Entities/RefreshToken.cs new file mode 100644 index 0000000..3967707 --- /dev/null +++ b/RT.Domain/Entities/RefreshToken.cs @@ -0,0 +1,10 @@ +namespace RT.Domain.Entities; + +public class RefreshToken +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string UserId { get; set; } = null!; + public string Token { get; set; } = null!; + public DateTime ExpiresAt { get; set; } + public bool IsRevoked { get; set; } +} diff --git a/RT.Infrastructure/Class1.cs b/RT.Infrastructure/Class1.cs deleted file mode 100644 index 566adac..0000000 --- a/RT.Infrastructure/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace RT.Infrastructure; - -public class Class1 -{ - -} diff --git a/RT.Infrastructure/Data/AppDbContext.cs b/RT.Infrastructure/Data/AppDbContext.cs index f9d9dc0..f757286 100644 --- a/RT.Infrastructure/Data/AppDbContext.cs +++ b/RT.Infrastructure/Data/AppDbContext.cs @@ -1,9 +1,15 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using RT.Domain.Entities; +using RT.Infrastructure.Identity; namespace RT.Infrastructure.Data; -public class AppDbContext(DbContextOptions options) : DbContext(options) +public class AppDbContext(DbContextOptions options) + : IdentityDbContext(options) { + public DbSet RefreshTokens => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/RT.Infrastructure/Identity/ApplicationUser.cs b/RT.Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..526873c --- /dev/null +++ b/RT.Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Identity; + +namespace RT.Infrastructure.Identity; + +public class ApplicationUser : IdentityUser +{ + public bool MustChangePassword { get; set; } +} diff --git a/RT.Infrastructure/RT.Infrastructure.csproj b/RT.Infrastructure/RT.Infrastructure.csproj index cbb6297..e434ce8 100644 --- a/RT.Infrastructure/RT.Infrastructure.csproj +++ b/RT.Infrastructure/RT.Infrastructure.csproj @@ -6,8 +6,10 @@ + +