From 97905f9e19777cb2c9871b90e47306c0596f6b27 Mon Sep 17 00:00:00 2001 From: minhtrannhat Date: Tue, 24 Dec 2024 12:00:00 -0500 Subject: [PATCH] feat(api): add authentication and health check endpoints --- .../Controllers/AuthController.cs | 226 ++++++++++++++++++ .../Controllers/HealthController.cs | 60 +++++ 2 files changed, 286 insertions(+) create mode 100644 src/IncidentOps.Api/Controllers/AuthController.cs create mode 100644 src/IncidentOps.Api/Controllers/HealthController.cs diff --git a/src/IncidentOps.Api/Controllers/AuthController.cs b/src/IncidentOps.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..31386f0 --- /dev/null +++ b/src/IncidentOps.Api/Controllers/AuthController.cs @@ -0,0 +1,226 @@ +using IncidentOps.Api.Auth; +using IncidentOps.Contracts.Auth; +using IncidentOps.Domain.Entities; +using IncidentOps.Domain.Enums; +using IncidentOps.Infrastructure.Auth; +using IncidentOps.Infrastructure.Data.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OrgEntity = IncidentOps.Domain.Entities.Org; + +namespace IncidentOps.Api.Controllers; + +[ApiController] +[Route("v1/auth")] +public class AuthController : ControllerBase +{ + private readonly IUserRepository _userRepository; + private readonly IOrgRepository _orgRepository; + private readonly IOrgMemberRepository _orgMemberRepository; + private readonly IRefreshTokenRepository _refreshTokenRepository; + private readonly ITokenService _tokenService; + private readonly IPasswordService _passwordService; + private readonly JwtSettings _jwtSettings; + + public AuthController( + IUserRepository userRepository, + IOrgRepository orgRepository, + IOrgMemberRepository orgMemberRepository, + IRefreshTokenRepository refreshTokenRepository, + ITokenService tokenService, + IPasswordService passwordService, + JwtSettings jwtSettings) + { + _userRepository = userRepository; + _orgRepository = orgRepository; + _orgMemberRepository = orgMemberRepository; + _refreshTokenRepository = refreshTokenRepository; + _tokenService = tokenService; + _passwordService = passwordService; + _jwtSettings = jwtSettings; + } + + [HttpPost("register")] + public async Task> Register([FromBody] RegisterRequest request) + { + var existingUser = await _userRepository.GetByEmailAsync(request.Email); + if (existingUser != null) + return Conflict(new { message = "Email already registered" }); + + var user = new User + { + Id = Guid.NewGuid(), + Email = request.Email.ToLowerInvariant(), + PasswordHash = _passwordService.HashPassword(request.Password), + DisplayName = request.DisplayName, + CreatedAt = DateTime.UtcNow + }; + await _userRepository.CreateAsync(user); + + // Create a default org for the user + var org = new OrgEntity + { + Id = Guid.NewGuid(), + Name = $"{request.DisplayName}'s Org", + Slug = $"org-{Guid.NewGuid():N}".Substring(0, 20), + CreatedAt = DateTime.UtcNow + }; + await _orgRepository.CreateAsync(org); + + var member = new OrgMember + { + Id = Guid.NewGuid(), + OrgId = org.Id, + UserId = user.Id, + Role = OrgRole.Admin, + CreatedAt = DateTime.UtcNow + }; + await _orgMemberRepository.CreateAsync(member); + + return await GenerateAuthResponse(user, org, member.Role); + } + + [HttpPost("login")] + public async Task> Login([FromBody] LoginRequest request) + { + var user = await _userRepository.GetByEmailAsync(request.Email); + if (user == null || !_passwordService.VerifyPassword(request.Password, user.PasswordHash)) + return Unauthorized(new { message = "Invalid credentials" }); + + var orgs = await _orgRepository.GetByUserIdAsync(user.Id); + if (orgs.Count == 0) + return Unauthorized(new { message = "User has no organizations" }); + + OrgEntity activeOrg; + if (request.OrgId.HasValue) + { + activeOrg = orgs.FirstOrDefault(o => o.Id == request.OrgId.Value) + ?? throw new InvalidOperationException("User is not a member of the specified organization"); + } + else + { + activeOrg = orgs.First(); + } + + var member = await _orgMemberRepository.GetByUserAndOrgAsync(user.Id, activeOrg.Id); + if (member == null) + return Unauthorized(new { message = "User is not a member of the organization" }); + + return await GenerateAuthResponse(user, activeOrg, member.Role); + } + + [HttpPost("refresh")] + public async Task> Refresh([FromBody] RefreshRequest request) + { + var tokenHash = _tokenService.HashToken(request.RefreshToken); + var refreshToken = await _refreshTokenRepository.GetByHashAsync(tokenHash); + if (refreshToken == null) + return Unauthorized(new { message = "Invalid refresh token" }); + + var user = await _userRepository.GetByIdAsync(refreshToken.UserId); + if (user == null) + return Unauthorized(new { message = "User not found" }); + + var org = await _orgRepository.GetByIdAsync(refreshToken.ActiveOrgId); + if (org == null) + return Unauthorized(new { message = "Organization not found" }); + + var member = await _orgMemberRepository.GetByUserAndOrgAsync(user.Id, org.Id); + if (member == null) + return Unauthorized(new { message = "User is not a member of the organization" }); + + // Rotate refresh token + await _refreshTokenRepository.RevokeAsync(refreshToken.Id); + + return await GenerateAuthResponse(user, org, member.Role); + } + + [HttpPost("switch-org")] + public async Task> SwitchOrg([FromBody] SwitchOrgRequest request) + { + var tokenHash = _tokenService.HashToken(request.RefreshToken); + var refreshToken = await _refreshTokenRepository.GetByHashAsync(tokenHash); + if (refreshToken == null) + return Unauthorized(new { message = "Invalid refresh token" }); + + var user = await _userRepository.GetByIdAsync(refreshToken.UserId); + if (user == null) + return Unauthorized(new { message = "User not found" }); + + var org = await _orgRepository.GetByIdAsync(request.OrgId); + if (org == null) + return NotFound(new { message = "Organization not found" }); + + var member = await _orgMemberRepository.GetByUserAndOrgAsync(user.Id, org.Id); + if (member == null) + return Forbidden("User is not a member of the organization"); + + // Rotate refresh token with new org + await _refreshTokenRepository.RevokeAsync(refreshToken.Id); + + return await GenerateAuthResponse(user, org, member.Role); + } + + [HttpPost("logout")] + public async Task Logout([FromBody] LogoutRequest request) + { + var tokenHash = _tokenService.HashToken(request.RefreshToken); + var refreshToken = await _refreshTokenRepository.GetByHashAsync(tokenHash); + if (refreshToken != null) + { + await _refreshTokenRepository.RevokeAsync(refreshToken.Id); + } + + return NoContent(); + } + + [Authorize] + [HttpGet("/v1/me")] + public async Task> Me() + { + var ctx = User.GetRequestContext(); + var user = await _userRepository.GetByIdAsync(ctx.UserId); + if (user == null) + return NotFound(); + + var org = await _orgRepository.GetByIdAsync(ctx.OrgId); + if (org == null) + return NotFound(); + + return new MeResponse( + user.Id, + user.Email, + user.DisplayName, + new ActiveOrgDto(org.Id, org.Name, org.Slug, ctx.Role.ToString().ToLowerInvariant()) + ); + } + + private async Task> GenerateAuthResponse(User user, OrgEntity org, OrgRole role) + { + var accessToken = _tokenService.GenerateAccessToken(user.Id, org.Id, role); + var refreshTokenValue = _tokenService.GenerateRefreshToken(); + var refreshTokenHash = _tokenService.HashToken(refreshTokenValue); + + var refreshToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = user.Id, + TokenHash = refreshTokenHash, + ActiveOrgId = org.Id, + ExpiresAt = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpirationDays), + CreatedAt = DateTime.UtcNow + }; + await _refreshTokenRepository.CreateAsync(refreshToken); + + return new AuthResponse( + accessToken, + refreshTokenValue, + new ActiveOrgDto(org.Id, org.Name, org.Slug, role.ToString().ToLowerInvariant()) + ); + } + + private ObjectResult Forbidden(string message) + { + return StatusCode(403, new { message }); + } +} diff --git a/src/IncidentOps.Api/Controllers/HealthController.cs b/src/IncidentOps.Api/Controllers/HealthController.cs new file mode 100644 index 0000000..e2cfa62 --- /dev/null +++ b/src/IncidentOps.Api/Controllers/HealthController.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Mvc; +using Npgsql; +using StackExchange.Redis; + +namespace IncidentOps.Api.Controllers; + +[ApiController] +public class HealthController : ControllerBase +{ + private readonly IConfiguration _configuration; + + public HealthController(IConfiguration configuration) + { + _configuration = configuration; + } + + [HttpGet("healthz")] + public IActionResult Healthz() + { + return Ok(new { status = "healthy" }); + } + + [HttpGet("readyz")] + public async Task Readyz() + { + var checks = new Dictionary(); + + // Check PostgreSQL + try + { + var connectionString = _configuration.GetConnectionString("Postgres"); + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + checks["postgres"] = "healthy"; + } + catch (Exception ex) + { + checks["postgres"] = $"unhealthy: {ex.Message}"; + } + + // Check Redis + try + { + var redisConnectionString = _configuration["Redis:ConnectionString"]; + var redis = await ConnectionMultiplexer.ConnectAsync(redisConnectionString!); + var db = redis.GetDatabase(); + await db.PingAsync(); + checks["redis"] = "healthy"; + } + catch (Exception ex) + { + checks["redis"] = $"unhealthy: {ex.Message}"; + } + + var allHealthy = checks.Values.All(v => v == "healthy"); + return allHealthy + ? Ok(new { status = "ready", checks }) + : StatusCode(503, new { status = "not ready", checks }); + } +}