Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Server/Auth/OperatorAuthService.cs
Matt Batchelder c6d46098dd feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00

103 lines
3.4 KiB
C#

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using OTSSignsOrchestrator.Server.Data;
using OTSSignsOrchestrator.Server.Data.Entities;
namespace OTSSignsOrchestrator.Server.Auth;
public class OperatorAuthService
{
private readonly OrchestratorDbContext _db;
private readonly JwtOptions _jwt;
private readonly ILogger<OperatorAuthService> _logger;
public OperatorAuthService(
OrchestratorDbContext db,
IOptions<JwtOptions> jwt,
ILogger<OperatorAuthService> logger)
{
_db = db;
_jwt = jwt.Value;
_logger = logger;
}
public async Task<(string Jwt, string RefreshToken)> LoginAsync(string email, string password)
{
var op = await _db.Operators.FirstOrDefaultAsync(
o => o.Email == email.Trim().ToLowerInvariant());
if (op is null || !BCrypt.Net.BCrypt.Verify(password, op.PasswordHash))
{
_logger.LogWarning("Login failed for {Email}", email);
throw new UnauthorizedAccessException("Invalid email or password.");
}
_logger.LogInformation("Operator {Email} logged in", op.Email);
var jwt = GenerateJwt(op);
var refresh = await CreateRefreshTokenAsync(op.Id);
return (jwt, refresh);
}
public async Task<string> RefreshAsync(string refreshToken)
{
var token = await _db.RefreshTokens
.Include(r => r.Operator)
.FirstOrDefaultAsync(r => r.Token == refreshToken);
if (token is null || token.RevokedAt is not null || token.ExpiresAt < DateTime.UtcNow)
throw new UnauthorizedAccessException("Invalid or expired refresh token.");
// Revoke the used token (single-use rotation)
token.RevokedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
_logger.LogInformation("Refresh token used for operator {Email}", token.Operator.Email);
return GenerateJwt(token.Operator);
}
private string GenerateJwt(Operator op)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.Key));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, op.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, op.Email),
new Claim(ClaimTypes.Name, op.Email),
new Claim(ClaimTypes.Role, op.Role.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var token = new JwtSecurityToken(
issuer: _jwt.Issuer,
audience: _jwt.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private async Task<string> CreateRefreshTokenAsync(Guid operatorId)
{
var tokenValue = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
_db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
OperatorId = operatorId,
Token = tokenValue,
ExpiresAt = DateTime.UtcNow.AddDays(7),
});
await _db.SaveChangesAsync();
return tokenValue;
}
}