feat(api): add authentication and health check endpoints

This commit is contained in:
2024-12-24 12:00:00 -05:00
parent 0aac1b6dc7
commit 97905f9e19
2 changed files with 286 additions and 0 deletions

View File

@@ -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<ActionResult<AuthResponse>> 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<ActionResult<AuthResponse>> 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<ActionResult<AuthResponse>> 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<ActionResult<AuthResponse>> 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<IActionResult> 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<ActionResult<MeResponse>> 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<ActionResult<AuthResponse>> 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 });
}
}

View File

@@ -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<IActionResult> Readyz()
{
var checks = new Dictionary<string, string>();
// 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 });
}
}