From 3d29d0859577942d6174992c5945a3c77064140c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:45:52 +0000 Subject: [PATCH 1/2] feat: add Identity API controller with JWT auth and BCrypt hashing - Add JWT authentication middleware with bearer token validation - Implement IdentityService with BCrypt password hashing and JWT generation - Replace scaffold controller with full register/login/users endpoints - Add [Authorize] protection to user listing endpoints - Configure JWT settings in appsettings.json - Register DI services for IIdentityService --- .../Controllers/IdentityController.cs | 56 ++++++-- .../Identity/Identity.API/Identity.API.csproj | 3 + src/Services/Identity/Identity.API/Program.cs | 33 +++++ .../Identity/Identity.API/appsettings.json | 6 + .../Identity.Infrastructure.csproj | 3 + .../Services/IdentityService.cs | 126 ++++++++++++++++++ 6 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 src/Services/Identity/Identity.Infrastructure/Services/IdentityService.cs diff --git a/src/Services/Identity/Identity.API/Controllers/IdentityController.cs b/src/Services/Identity/Identity.API/Controllers/IdentityController.cs index 873b3f2..54a8753 100644 --- a/src/Services/Identity/Identity.API/Controllers/IdentityController.cs +++ b/src/Services/Identity/Identity.API/Controllers/IdentityController.cs @@ -1,4 +1,7 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Identity.Domain.DTOs; +using Identity.Domain.Interfaces; namespace Identity.API.Controllers; @@ -6,24 +9,55 @@ namespace Identity.API.Controllers; [Route("api/[controller]")] public class IdentityController : ControllerBase { - private readonly ILogger _logger; + private readonly IIdentityService _identityService; - public IdentityController(ILogger logger) + public IdentityController(IIdentityService identityService) { - _logger = logger; + _identityService = identityService; } - [HttpGet] - public IActionResult GetAll() + [HttpPost("register")] + [ProducesResponseType(typeof(AppUserDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Register([FromBody] RegisterRequest request) { - // TODO: Implement — migrate logic from monolith's IdentityController - return Ok(new { service = "Identity", status = "scaffold" }); + var result = await _identityService.RegisterAsync(request); + if (!result.Success) + return BadRequest(new { error = result.ErrorMessage }); + return CreatedAtAction(nameof(GetById), new { id = result.User!.Id }, result.User); } - [HttpGet("{id}")] - public IActionResult GetById(int id) + [HttpPost("login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Login([FromBody] LoginRequest request) { - // TODO: Implement — migrate logic from monolith - return Ok(new { service = "Identity", id }); + var result = await _identityService.LoginAsync(request); + if (!result.Success) + return Unauthorized(new { error = result.ErrorMessage }); + return Ok(new { token = result.Token }); + } + + [HttpGet("users")] + [Authorize] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetUsers() + { + var users = await _identityService.GetAllUsersAsync(); + return Ok(users); + } + + [HttpGet("users/{id:guid}")] + [Authorize] + [ProducesResponseType(typeof(AppUserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetById(Guid id) + { + var user = await _identityService.GetUserByIdAsync(id); + if (user == null) + return NotFound(); + return Ok(user); } } diff --git a/src/Services/Identity/Identity.API/Identity.API.csproj b/src/Services/Identity/Identity.API/Identity.API.csproj index 9b3e932..1cc4cd5 100644 --- a/src/Services/Identity/Identity.API/Identity.API.csproj +++ b/src/Services/Identity/Identity.API/Identity.API.csproj @@ -11,7 +11,10 @@ + + + diff --git a/src/Services/Identity/Identity.API/Program.cs b/src/Services/Identity/Identity.API/Program.cs index c8312eb..4df1603 100644 --- a/src/Services/Identity/Identity.API/Program.cs +++ b/src/Services/Identity/Identity.API/Program.cs @@ -1,4 +1,9 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Identity.Domain.Interfaces; using Identity.Infrastructure.Data; +using Identity.Infrastructure.Services; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -8,9 +13,34 @@ builder.Services.AddSwaggerGen(); builder.Services.AddHealthChecks(); +// JWT Authentication +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)) + }; +}); + +builder.Services.AddAuthorization(); + builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(); + var app = builder.Build(); using (var scope = app.Services.CreateScope()) @@ -25,6 +55,9 @@ app.UseSwaggerUI(); } +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.MapHealthChecks("/healthz"); diff --git a/src/Services/Identity/Identity.API/appsettings.json b/src/Services/Identity/Identity.API/appsettings.json index e60a780..43e8611 100644 --- a/src/Services/Identity/Identity.API/appsettings.json +++ b/src/Services/Identity/Identity.API/appsettings.json @@ -7,5 +7,11 @@ }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=identitydb;Username=postgres;Password=postgres" + }, + "Jwt": { + "Secret": "QuickApp_Microservices_SuperSecret_Key_For_Dev_Only_Min_32_Chars!", + "Issuer": "quickapp-identity", + "Audience": "quickapp-api", + "ExpirationMinutes": 60 } } diff --git a/src/Services/Identity/Identity.Infrastructure/Identity.Infrastructure.csproj b/src/Services/Identity/Identity.Infrastructure/Identity.Infrastructure.csproj index ec3c7aa..7f5235e 100644 --- a/src/Services/Identity/Identity.Infrastructure/Identity.Infrastructure.csproj +++ b/src/Services/Identity/Identity.Infrastructure/Identity.Infrastructure.csproj @@ -8,7 +8,10 @@ + + + diff --git a/src/Services/Identity/Identity.Infrastructure/Services/IdentityService.cs b/src/Services/Identity/Identity.Infrastructure/Services/IdentityService.cs new file mode 100644 index 0000000..e2eb5b9 --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Services/IdentityService.cs @@ -0,0 +1,126 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Identity.Domain.DTOs; +using Identity.Domain.Entities; +using Identity.Domain.Interfaces; +using Identity.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Identity.Infrastructure.Services; + +public class IdentityService : IIdentityService +{ + private readonly IdentityDbContext _context; + private readonly IConfiguration _configuration; + + public IdentityService(IdentityDbContext context, IConfiguration configuration) + { + _context = context; + _configuration = configuration; + } + + public async Task RegisterAsync(RegisterRequest request) + { + if (await _context.Users.AnyAsync(u => u.UserName == request.UserName)) + { + return new RegisterResult { Success = false, ErrorMessage = "Username is already taken." }; + } + + if (await _context.Users.AnyAsync(u => u.Email == request.Email)) + { + return new RegisterResult { Success = false, ErrorMessage = "Email is already registered." }; + } + + var user = new AppUser + { + Id = Guid.NewGuid(), + UserName = request.UserName, + Email = request.Email, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), + FullName = request.FullName, + JobTitle = request.JobTitle, + IsEnabled = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + return new RegisterResult + { + Success = true, + User = MapToDto(user) + }; + } + + public async Task LoginAsync(LoginRequest request) + { + var user = await _context.Users.FirstOrDefaultAsync(u => u.UserName == request.UserName); + + if (user == null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) + { + return new LoginResult { Success = false, ErrorMessage = "Invalid username or password." }; + } + + var token = GenerateJwtToken(user); + + return new LoginResult { Success = true, Token = token }; + } + + public async Task GetUserByIdAsync(Guid id) + { + var user = await _context.Users.FindAsync(id); + return user == null ? null : MapToDto(user); + } + + public async Task> GetAllUsersAsync() + { + var users = await _context.Users.ToListAsync(); + return users.Select(MapToDto); + } + + private string GenerateJwtToken(AppUser user) + { + var secret = _configuration["Jwt:Secret"]!; + var issuer = _configuration["Jwt:Issuer"]; + var audience = _configuration["Jwt:Audience"]; + var expirationMinutes = int.Parse(_configuration["Jwt:ExpirationMinutes"]!); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Name, user.UserName), + new Claim(JwtRegisteredClaimNames.Email, user.Email), + new Claim(ClaimTypes.Role, "User") + }; + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(expirationMinutes), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static AppUserDto MapToDto(AppUser user) + { + return new AppUserDto + { + Id = user.Id, + UserName = user.UserName, + Email = user.Email, + FullName = user.FullName, + JobTitle = user.JobTitle, + IsEnabled = user.IsEnabled + }; + } +} From 7514f1f8c0637a255cdd42c2162f5efd81809f01 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:50:36 +0000 Subject: [PATCH 2/2] fix: reject login for disabled accounts Check user.IsEnabled after credential verification before issuing JWT token. --- .../Identity.Infrastructure/Services/IdentityService.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Services/Identity/Identity.Infrastructure/Services/IdentityService.cs b/src/Services/Identity/Identity.Infrastructure/Services/IdentityService.cs index e2eb5b9..7bea4c0 100644 --- a/src/Services/Identity/Identity.Infrastructure/Services/IdentityService.cs +++ b/src/Services/Identity/Identity.Infrastructure/Services/IdentityService.cs @@ -66,6 +66,11 @@ public async Task LoginAsync(LoginRequest request) return new LoginResult { Success = false, ErrorMessage = "Invalid username or password." }; } + if (!user.IsEnabled) + { + return new LoginResult { Success = false, ErrorMessage = "This account has been disabled." }; + } + var token = GenerateJwtToken(user); return new LoginResult { Success = true, Token = token };