feat(api): add authentication and health check endpoints
This commit is contained in:
226
src/IncidentOps.Api/Controllers/AuthController.cs
Normal file
226
src/IncidentOps.Api/Controllers/AuthController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
60
src/IncidentOps.Api/Controllers/HealthController.cs
Normal file
60
src/IncidentOps.Api/Controllers/HealthController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user