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