feat(admin): 예전/최신 어드민 통합 — 실데이터 복원 + 인증 아키텍처 정리

예전 배포본(HEAD)의 상세 기능을 새 아키텍처(인증=.NET 백엔드, 데이터=Supabase)
위에 실데이터로 복원. 예전 HEAD는 서명 쿠키를 거부하고 위조 쿠키는 통과시키는
인증 결함이 있었고, 삭제된 페이지 다수는 백엔드 호출 0인 하드코딩 목업이었음.

인증/세션
- 로그인 이메일 전용화(username 폐지), 에러 키별 안내 메시지
- ADMIN_COOKIE_SECURE 옵션: TLS 없는 LAN HTTP 배포에서 Secure 쿠키 유실로
  로그인이 유지되지 않던 문제 해결 (login/logout route, admin-session, compose, .env.example)
- Supabase 미설정 시 우아한 저하: isSupabaseAdminConfigured + UnavailableAdminPanel

기능 복원 (실데이터)
- Release Hub: Forgejo API 실데이터(다운로드 수/SHA-256 체크섬/릴리스 이력)
- Ad Monetization: 데스크톱 미디에이션 10개 어댑터 로스터(fail-closed) + ad_reward_claims 통계
- License Issuer: 서버사이드 Ed25519 서명(/api/admin/license, super_admin 전용),
  개인키는 ADMIN_LICENSE_PRIVATE_KEY env로만, 발급 감사를 .NET AdminAuditEntries에 기록
- Service Models: STT 7종/LLM 5종 프리셋 드롭다운 + 자동채움
- 대시보드 ARR/MRR KPI: Supabase 구독 실집계(티어 월단가 기반)
- 사용자 상세 티어별 기능 배지(pro_plus 조건부)

.NET
- SuperAdminOnly 정책 추가, /api/admin/license-audit 엔드포인트, LicenseAuditDto
This commit is contained in:
Yun Chan 2026-08-23 23:38:08 +09:00
parent a9c9a1ca6e
commit 5a34f66981
66 changed files with 4471 additions and 3501 deletions

View file

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Dtos;
@ -13,16 +15,19 @@ namespace D3ROVoice.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = "ManagerOrAbove")]
public class AdminController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ISttProxyService _sttService;
private readonly IAdminOperationService _adminOperations;
private static readonly DateTime _serverStartTime = DateTime.UtcNow;
public AdminController(AppDbContext db, ISttProxyService sttService)
public AdminController(AppDbContext db, ISttProxyService sttService, IAdminOperationService adminOperations)
{
_db = db;
_sttService = sttService;
_adminOperations = adminOperations;
}
[HttpGet("stats")]
@ -75,73 +80,121 @@ public class AdminController : ControllerBase
public async Task<IActionResult> GetEndpoints()
{
var endpoints = await _db.ModelEndpoints
.AsNoTracking()
.OrderBy(m => m.Id)
.Select(endpoint => new
{
endpoint.Id,
endpoint.ModelId,
endpoint.ModelName,
endpoint.Provider,
endpoint.EndpointUrl,
ApiKey = endpoint.ApiKey == "" ? "" : "••••••••",
endpoint.CostPer1kPromptTokens,
endpoint.CostPer1kCompletionTokens,
endpoint.IsActive,
endpoint.CreatedAt
})
.ToListAsync();
return Ok(endpoints);
}
[HttpPost("endpoints")]
[Authorize(Policy = "AdminOrAbove")]
public async Task<IActionResult> CreateEndpoint([FromBody] CreateModelEndpointDto dto)
{
if (string.IsNullOrWhiteSpace(dto.ModelId) || string.IsNullOrWhiteSpace(dto.ModelName))
if (string.IsNullOrWhiteSpace(dto.ModelId) || string.IsNullOrWhiteSpace(dto.ModelName)
|| string.IsNullOrWhiteSpace(dto.Provider) || !IsValidEndpointUrl(dto.EndpointUrl)
|| dto.CostPer1kPromptTokens < 0 || dto.CostPer1kCompletionTokens < 0)
{
return BadRequest(new { message = "ModelId와 ModelName은 필수 항목입니다." });
return BadRequest(new { message = "모델 식별자, 공급자, 유효한 HTTP(S) URL과 0 이상의 비용이 필요합니다." });
}
var existing = await _db.ModelEndpoints.FirstOrDefaultAsync(m => m.ModelId == dto.ModelId);
if (existing != null)
{
return Conflict(new { message = "이미 존재하는 ModelId입니다." });
}
var endpoint = new ServiceModelEndpoint
{
ModelId = dto.ModelId.Trim(),
ModelName = dto.ModelName.Trim(),
Provider = dto.Provider.Trim(),
EndpointUrl = dto.EndpointUrl.Trim(),
ApiKey = dto.ApiKey ?? "",
CostPer1kPromptTokens = dto.CostPer1kPromptTokens,
CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
_db.ModelEndpoints.Add(endpoint);
await _db.SaveChangesAsync();
return Ok(endpoint);
return await ExecuteAdminMutationAsync(
"model_endpoint.create",
dto,
"model_endpoint",
_ => dto.ModelId.Trim(),
dto.Memo,
() => Task.FromResult<object?>(null),
async () =>
{
if (await _db.ModelEndpoints.AnyAsync(model => model.ModelId == dto.ModelId.Trim()))
throw new AdminOperationException("model_id_already_exists");
var endpoint = new ServiceModelEndpoint
{
ModelId = dto.ModelId.Trim(),
ModelName = dto.ModelName.Trim(),
Provider = dto.Provider.Trim(),
EndpointUrl = dto.EndpointUrl.Trim(),
ApiKey = dto.ApiKey?.Trim() ?? "",
CostPer1kPromptTokens = dto.CostPer1kPromptTokens,
CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
_db.ModelEndpoints.Add(endpoint);
await _db.SaveChangesAsync();
return ToModelEndpointDto(endpoint);
});
}
[HttpPut("endpoints/{id}")]
[Authorize(Policy = "AdminOrAbove")]
public async Task<IActionResult> UpdateEndpoint(int id, [FromBody] UpdateModelEndpointDto dto)
{
var endpoint = await _db.ModelEndpoints.FindAsync(id);
if (endpoint == null) return NotFound();
if (id <= 0 || string.IsNullOrWhiteSpace(dto.ModelName) || string.IsNullOrWhiteSpace(dto.Provider)
|| !IsValidEndpointUrl(dto.EndpointUrl) || dto.CostPer1kPromptTokens < 0 || dto.CostPer1kCompletionTokens < 0)
return BadRequest(new { message = "유효한 모델 엔드포인트 값이 필요합니다." });
endpoint.ModelName = dto.ModelName.Trim();
endpoint.Provider = dto.Provider.Trim();
endpoint.EndpointUrl = dto.EndpointUrl.Trim();
endpoint.ApiKey = dto.ApiKey ?? "";
endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens;
endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens;
endpoint.IsActive = dto.IsActive;
await _db.SaveChangesAsync();
return Ok(endpoint);
return await ExecuteAdminMutationAsync(
"model_endpoint.update",
new { id, dto },
"model_endpoint",
_ => id.ToString(),
dto.Memo,
async () => await _db.ModelEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
.Select(endpoint => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, endpoint.IsActive })
.SingleOrDefaultAsync(),
async () =>
{
var endpoint = await _db.ModelEndpoints.FindAsync(id)
?? throw new AdminOperationException("model_endpoint_not_found");
endpoint.ModelName = dto.ModelName.Trim();
endpoint.Provider = dto.Provider.Trim();
endpoint.EndpointUrl = dto.EndpointUrl.Trim();
if (dto.ApiKey != null) endpoint.ApiKey = dto.ApiKey.Trim();
endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens;
endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens;
endpoint.IsActive = dto.IsActive;
await _db.SaveChangesAsync();
return ToModelEndpointDto(endpoint);
});
}
[HttpDelete("endpoints/{id}")]
public async Task<IActionResult> DeleteEndpoint(int id)
[Authorize(Policy = "AdminOrAbove")]
public async Task<IActionResult> DeleteEndpoint(int id, [FromBody] AdminActionDto dto)
{
var endpoint = await _db.ModelEndpoints.FindAsync(id);
if (endpoint == null) return NotFound();
_db.ModelEndpoints.Remove(endpoint);
await _db.SaveChangesAsync();
return Ok(new { message = "삭제되었습니다." });
if (id <= 0) return BadRequest();
return await ExecuteAdminMutationAsync(
"model_endpoint.delete",
new { id, dto.Memo },
"model_endpoint",
_ => id.ToString(),
dto.Memo,
async () => await _db.ModelEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
.Select(endpoint => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, endpoint.IsActive })
.SingleOrDefaultAsync(),
async () =>
{
var endpoint = await _db.ModelEndpoints.FindAsync(id)
?? throw new AdminOperationException("model_endpoint_not_found");
_db.ModelEndpoints.Remove(endpoint);
await _db.SaveChangesAsync();
return new { message = "삭제되었습니다.", id };
});
}
// ── STT / Transcription Provider Endpoints ────────────────────────────
@ -163,45 +216,88 @@ public class AdminController : ControllerBase
}
[HttpPost("stt-endpoints")]
[Authorize(Policy = "AdminOrAbove")]
public async Task<IActionResult> CreateSttEndpoint([FromBody] CreateSttEndpointDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Name) || string.IsNullOrWhiteSpace(dto.EndpointUrl))
if (string.IsNullOrWhiteSpace(dto.Name) || !IsValidEndpointUrl(dto.EndpointUrl)
|| dto.CostPerMinute < 0 || dto.CostPerSecond < 0 || dto.FallbackPriority < 1)
{
return BadRequest(new { message = "이름과 Endpoint URL은 필수입니다." });
return BadRequest(new { message = "유효한 STT 이름, HTTP(S) URL, 비용, 우선순위가 필요합니다." });
}
var endpoint = await _sttService.CreateEndpointAsync(dto);
return Ok(endpoint);
return await ExecuteAdminMutationAsync(
"stt_endpoint.create",
dto,
"stt_endpoint",
result => ((SttProviderEndpointDto)result).Id.ToString(),
dto.Memo,
() => Task.FromResult<object?>(null),
async () => await _sttService.CreateEndpointAsync(dto));
}
[HttpPut("stt-endpoints/{id}")]
[Authorize(Policy = "AdminOrAbove")]
public async Task<IActionResult> UpdateSttEndpoint(int id, [FromBody] UpdateSttEndpointDto dto)
{
try
{
var endpoint = await _sttService.UpdateEndpointAsync(id, dto);
return Ok(endpoint);
}
catch (KeyNotFoundException)
{
return NotFound(new { message = $"STT Endpoint {id} not found." });
}
if (id <= 0 || string.IsNullOrWhiteSpace(dto.Name) || !IsValidEndpointUrl(dto.EndpointUrl)
|| dto.CostPerMinute < 0 || dto.CostPerSecond < 0 || dto.FallbackPriority < 1)
return BadRequest(new { message = "유효한 STT 엔드포인트 값이 필요합니다." });
return await ExecuteAdminMutationAsync(
"stt_endpoint.update",
new { id, dto },
"stt_endpoint",
_ => id.ToString(),
dto.Memo,
async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
.Select(endpoint => new { endpoint.Id, endpoint.Name, endpoint.ProviderType, endpoint.EndpointUrl, endpoint.ModelId, endpoint.IsDefault, endpoint.IsActive, endpoint.FallbackPriority })
.SingleOrDefaultAsync(),
async () =>
{
try { return await _sttService.UpdateEndpointAsync(id, dto); }
catch (KeyNotFoundException) { throw new AdminOperationException("stt_endpoint_not_found"); }
});
}
[HttpDelete("stt-endpoints/{id}")]
public async Task<IActionResult> DeleteSttEndpoint(int id)
[Authorize(Policy = "AdminOrAbove")]
public async Task<IActionResult> DeleteSttEndpoint(int id, [FromBody] AdminActionDto dto)
{
var deleted = await _sttService.DeleteEndpointAsync(id);
if (!deleted) return NotFound();
return Ok(new { message = "STT 엔드포인트가 성공적으로 삭제되었습니다." });
if (id <= 0) return BadRequest();
return await ExecuteAdminMutationAsync(
"stt_endpoint.delete",
new { id, dto.Memo },
"stt_endpoint",
_ => id.ToString(),
dto.Memo,
async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
.Select(endpoint => new { endpoint.Id, endpoint.Name, endpoint.ProviderType, endpoint.EndpointUrl, endpoint.ModelId, endpoint.IsDefault, endpoint.IsActive, endpoint.FallbackPriority })
.SingleOrDefaultAsync(),
async () =>
{
if (!await _sttService.DeleteEndpointAsync(id)) throw new AdminOperationException("stt_endpoint_not_found");
return new { message = "STT 엔드포인트가 성공적으로 삭제되었습니다.", id };
});
}
[HttpPost("stt-endpoints/{id}/set-default")]
public async Task<IActionResult> SetDefaultSttEndpoint(int id)
[Authorize(Policy = "AdminOrAbove")]
public async Task<IActionResult> SetDefaultSttEndpoint(int id, [FromBody] AdminActionDto dto)
{
var success = await _sttService.SetDefaultEndpointAsync(id);
if (!success) return NotFound();
return Ok(new { id, isDefault = true, success = true, message = "기본 클라우드 전사 프로바이더로 설정되었습니다." });
if (id <= 0) return BadRequest();
return await ExecuteAdminMutationAsync(
"stt_endpoint.set_default",
new { id, dto.Memo },
"stt_endpoint",
_ => id.ToString(),
dto.Memo,
async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.IsDefault)
.Select(endpoint => new { endpoint.Id, endpoint.Name }).ToListAsync(),
async () =>
{
if (!await _sttService.SetDefaultEndpointAsync(id)) throw new AdminOperationException("stt_endpoint_not_found");
return new { id, isDefault = true, success = true, message = "기본 클라우드 전사 프로바이더로 설정되었습니다." };
});
}
[HttpPost("stt-endpoints/{id}/test")]
@ -212,9 +308,15 @@ public class AdminController : ControllerBase
}
[HttpPost("stt-endpoints/test-direct")]
public async Task<IActionResult> TestSttDirect([FromQuery] string endpointUrl, [FromQuery] string? apiKey)
[Authorize(Policy = "AdminOrAbove")]
public async Task<IActionResult> TestSttDirect([FromBody] DirectSttTestDto dto)
{
var result = await _sttService.TestEndpointAsync(0, apiKey, endpointUrl);
if (!IsValidEndpointUrl(dto.EndpointUrl) || !Uri.TryCreate(dto.EndpointUrl, UriKind.Absolute, out var endpointUri))
{
return BadRequest(new { message = "유효한 HTTP(S) Endpoint URL이 필요합니다." });
}
var result = await _sttService.TestEndpointAsync(0, dto.ApiKey, endpointUri.AbsoluteUri);
return Ok(result);
}
@ -272,4 +374,113 @@ public class AdminController : ControllerBase
return Ok(report);
}
// 오프라인 Ed25519 라이선스 발급 감사. admin 콘솔이 서명 후 이 엔드포인트로 기록만 남긴다.
[HttpPost("license-audit")]
[Authorize(Policy = "SuperAdminOnly")]
[RequestSizeLimit(8 * 1024)]
public async Task<IActionResult> RecordLicenseAudit([FromBody] LicenseAuditDto dto)
{
var actorEmail = User.FindFirstValue(ClaimTypes.Email)?.Trim().ToLowerInvariant() ?? string.Empty;
if (actorEmail.Length is < 3 or > 150) return Unauthorized(new { error = "invalid_actor" });
var licenseId = dto.LicenseId?.Trim() ?? string.Empty;
var customerEmail = dto.CustomerEmail?.Trim().ToLowerInvariant() ?? string.Empty;
var tier = dto.Tier?.Trim() ?? string.Empty;
var validity = dto.Validity?.Trim() ?? string.Empty;
if (licenseId.Length is < 3 or > 200) return BadRequest(new { error = "invalid_license_id" });
if (customerEmail.Length is < 3 or > 150 || !customerEmail.Contains('@')) return BadRequest(new { error = "invalid_customer_email" });
if (tier is not ("pro" or "pro_plus" or "team" or "enterprise")) return BadRequest(new { error = "invalid_tier" });
if (validity is not ("30d" or "365d" or "lifetime")) return BadRequest(new { error = "invalid_validity" });
var afterJson = JsonSerializer.Serialize(new
{
licenseId,
customerEmail,
tier,
validity,
expiresAt = dto.ExpiresAt
});
_db.AdminAuditEntries.Add(new AdminAuditEntry
{
ActorEmail = actorEmail,
Action = "license.issue",
TargetType = "license",
TargetId = licenseId,
AfterJson = afterJson,
Memo = $"Issued {tier} license ({validity}) for {customerEmail}",
IdempotencyKey = Guid.NewGuid().ToString("D"),
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
return Ok(new { success = true });
}
private static object ToModelEndpointDto(ServiceModelEndpoint endpoint) => new
{
endpoint.Id,
endpoint.ModelId,
endpoint.ModelName,
endpoint.Provider,
endpoint.EndpointUrl,
ApiKey = string.IsNullOrWhiteSpace(endpoint.ApiKey) ? string.Empty : "••••••••",
endpoint.CostPer1kPromptTokens,
endpoint.CostPer1kCompletionTokens,
endpoint.IsActive,
endpoint.CreatedAt
};
private static bool IsValidEndpointUrl(string? value)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || !string.IsNullOrEmpty(uri.UserInfo)) return false;
if (uri.Scheme == Uri.UriSchemeHttp) return uri.IsLoopback;
return uri.Scheme == Uri.UriSchemeHttps;
}
private async Task<IActionResult> ExecuteAdminMutationAsync(
string operation,
object request,
string targetType,
Func<object, string> targetId,
string? memo,
Func<Task<object?>> readBefore,
Func<Task<object>> mutate)
{
var actorEmail = User.FindFirstValue(ClaimTypes.Email);
if (string.IsNullOrWhiteSpace(actorEmail)) return Unauthorized(new { message = "관리자 이메일 claim이 필요합니다." });
var idempotencyKey = Request.Headers["Idempotency-Key"].ToString();
try
{
var result = await _adminOperations.ExecuteAsync(
actorEmail,
operation,
idempotencyKey,
request,
targetType,
targetId,
memo ?? string.Empty,
readBefore,
mutate);
return Ok(result);
}
catch (AdminOperationException ex) when (ex.Message == "idempotency_key_reused_with_different_request")
{
return Conflict(new { message = ex.Message });
}
catch (AdminOperationException ex) when (ex.Message.EndsWith("_not_found", StringComparison.Ordinal))
{
return NotFound(new { message = ex.Message });
}
catch (AdminOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
catch (DbUpdateException)
{
return Conflict(new { message = "admin_operation_conflict" });
}
}
}

View file

@ -9,6 +9,14 @@ public record LoginDto(string Email, string Password);
public record AuthResponseDto(string Token, string Email, string Role, DateTime ExpiresAt);
public record UserInfoDto(int Id, string Email, string Role, DateTime CreatedAt, DateTime? LastLoginAt, bool IsActive);
// License issuance audit trail (offline Ed25519 license keys issued from the admin console)
public record LicenseAuditDto(
string LicenseId,
string CustomerEmail,
string Tier,
string Validity,
long? ExpiresAt);
// LLM DTOs
public record LlmGenerateRequest(string Prompt, string? Model = null, string? SystemPrompt = null, double Temperature = 0.7, int MaxTokens = 2048);
public record LlmGenerateResponse(string Text, string Model, int PromptTokens, int CompletionTokens, double TotalDurationMs, decimal Cost);
@ -23,7 +31,8 @@ public record CreateModelEndpointDto(
string EndpointUrl,
string ApiKey,
decimal CostPer1kPromptTokens,
decimal CostPer1kCompletionTokens
decimal CostPer1kCompletionTokens,
string? Memo = null
);
public record UpdateModelEndpointDto(
@ -33,7 +42,8 @@ public record UpdateModelEndpointDto(
string ApiKey,
decimal CostPer1kPromptTokens,
decimal CostPer1kCompletionTokens,
bool IsActive
bool IsActive,
string? Memo = null
);
public record ServerStatsDto(
@ -117,7 +127,8 @@ public record CreateSttEndpointDto(
bool IsDefault,
bool IsActive,
int FallbackPriority,
string? ExtraHeadersJson
string? ExtraHeadersJson,
string? Memo = null
);
public record UpdateSttEndpointDto(
@ -135,9 +146,12 @@ public record UpdateSttEndpointDto(
bool IsDefault,
bool IsActive,
int FallbackPriority,
string? ExtraHeadersJson
string? ExtraHeadersJson,
string? Memo = null
);
public record AdminActionDto(string? Memo);
public record SttTestResultDto(
bool Success,
string Message,
@ -147,6 +161,8 @@ public record SttTestResultDto(
string? ModelId
);
public record DirectSttTestDto(string EndpointUrl, string? ApiKey);
public record SttUsageReportDto(
int TotalTranscriptions,
double TotalAudioMinutes,
@ -172,4 +188,3 @@ public record SttUserUsageSummaryDto(
double TotalAudioMinutes,
decimal TotalCost
);

View file

@ -2,9 +2,11 @@ using System.Text;
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
var serverStartTime = DateTime.UtcNow;
@ -13,6 +15,19 @@ var serverStartTime = DateTime.UtcNow;
builder.Services.AddControllers();
builder.Services.AddHttpClient();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
});
builder.Services.AddSwaggerGen(c =>
{
@ -45,11 +60,24 @@ builder.Services.AddDbContext<AppDbContext>(options =>
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ILlmProxyService, LlmProxyService>();
builder.Services.AddScoped<ISttProxyService, SttProxyService>();
builder.Services.AddScoped<IAdminOperationService, AdminOperationService>();
// JWT Authentication Configuration
var secretKey = builder.Configuration["Jwt:SecretKey"]
?? builder.Configuration["JWT_SECRET"]
?? "D3ROVoice_Super_Secure_Secret_Key_2026_Key!";
var secretKey = builder.Configuration["JWT_SECRET"];
if (string.IsNullOrWhiteSpace(secretKey) || Encoding.UTF8.GetByteCount(secretKey) < 32)
{
throw new InvalidOperationException("JWT_SECRET must contain at least 32 non-whitespace bytes.");
}
var jwtIssuer = builder.Configuration["JWT_ISSUER"];
if (string.IsNullOrWhiteSpace(jwtIssuer))
{
throw new InvalidOperationException("JWT_ISSUER is required.");
}
var jwtAudience = builder.Configuration["JWT_AUDIENCE"];
if (string.IsNullOrWhiteSpace(jwtAudience))
{
throw new InvalidOperationException("JWT_AUDIENCE is required.");
}
var keyBytes = Encoding.UTF8.GetBytes(secretKey);
builder.Services.AddAuthentication(options =>
@ -59,25 +87,103 @@ builder.Services.AddAuthentication(options =>
})
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuer = true,
ValidIssuer = jwtIssuer,
ValidateAudience = true,
ValidAudience = jwtAudience,
RequireExpirationTime = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
});
static string NormalizeAdminRole(string value) =>
value.Replace("_", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal)
.Trim()
.ToLowerInvariant();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ManagerOrAbove", policy =>
policy.RequireAuthenticatedUser().RequireAssertion(context =>
context.User.Claims
.Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role")
.Select(claim => NormalizeAdminRole(claim.Value))
.Any(role => role is "manager" or "admin" or "superadmin")));
options.AddPolicy("AdminOrAbove", policy =>
policy.RequireAuthenticatedUser().RequireAssertion(context =>
context.User.Claims
.Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role")
.Select(claim => NormalizeAdminRole(claim.Value))
.Any(role => role is "admin" or "superadmin")));
options.AddPolicy("SuperAdminOnly", policy =>
policy.RequireAuthenticatedUser().RequireAssertion(context =>
context.User.Claims
.Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role")
.Select(claim => NormalizeAdminRole(claim.Value))
.Any(role => role is "superadmin")));
});
var corsOriginsRaw = builder.Configuration["Cors:AllowedOrigins"]
?? builder.Configuration["CORS_ALLOWED_ORIGINS"];
if (string.IsNullOrWhiteSpace(corsOriginsRaw))
{
if (!builder.Environment.IsDevelopment())
{
throw new InvalidOperationException("CORS_ALLOWED_ORIGINS is required outside Development.");
}
corsOriginsRaw = "http://localhost:3000,http://localhost:3001,http://localhost:5173";
}
var corsOrigins = corsOriginsRaw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(origin => origin.TrimEnd('/'))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (corsOrigins.Length == 0 || corsOrigins.Any(origin =>
!Uri.TryCreate(origin, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|| !string.IsNullOrEmpty(uri.UserInfo)
|| !string.IsNullOrEmpty(uri.Query)
|| !string.IsNullOrEmpty(uri.Fragment)
|| uri.AbsolutePath != "/"))
{
throw new InvalidOperationException("CORS_ALLOWED_ORIGINS must be a comma-separated list of HTTP(S) origins without paths or wildcards.");
}
var allowedHosts = builder.Configuration["ALLOWED_HOSTS"];
if (string.IsNullOrWhiteSpace(allowedHosts))
{
if (!builder.Environment.IsDevelopment())
{
throw new InvalidOperationException("ALLOWED_HOSTS is required outside Development.");
}
allowedHosts = "localhost;127.0.0.1";
}
if (allowedHosts.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(host => host == "*" || host.Contains('/') || host.Contains('\\')))
{
throw new InvalidOperationException("ALLOWED_HOSTS must contain explicit semicolon-separated host names without wildcards or paths.");
}
builder.Configuration["AllowedHosts"] = allowedHosts;
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
options.AddPolicy("ConfiguredOrigins", policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(corsOrigins)
.AllowAnyMethod()
.AllowAnyHeader();
.AllowAnyHeader()
.SetPreflightMaxAge(TimeSpan.FromHours(1));
});
});
@ -88,6 +194,75 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
db.Database.ExecuteSqlRaw("""
CREATE TABLE IF NOT EXISTS "AdminOperationRequests" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_AdminOperationRequests" PRIMARY KEY AUTOINCREMENT,
"ActorEmail" TEXT NOT NULL,
"IdempotencyKey" TEXT NOT NULL,
"Operation" TEXT NOT NULL,
"RequestHash" TEXT NOT NULL,
"ResponseJson" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_AdminOperationRequests_ActorEmail_IdempotencyKey"
ON "AdminOperationRequests" ("ActorEmail", "IdempotencyKey");
CREATE TABLE IF NOT EXISTS "AdminAuditEntries" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_AdminAuditEntries" PRIMARY KEY AUTOINCREMENT,
"ActorEmail" TEXT NOT NULL,
"Action" TEXT NOT NULL,
"TargetType" TEXT NOT NULL,
"TargetId" TEXT NOT NULL,
"BeforeJson" TEXT NULL,
"AfterJson" TEXT NULL,
"Memo" TEXT NOT NULL,
"IdempotencyKey" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
ON "AdminAuditEntries" ("CreatedAt");
""");
// The former repository-wide SHA-256 password scheme and seeded admin
// credentials are compromised by design. Disable those rows so they can
// never authenticate; an operator provisions the administrator either via
// the one-time bootstrap token or the ADMIN_EMAIL/ADMIN_PASSWORD env pair
// below (auto-created only when no active administrator exists).
var legacyPasswordUsers = db.Users
.Where(user => !user.PasswordHash.StartsWith("AQAAAA"))
.ToList();
if (legacyPasswordUsers.Count > 0)
{
foreach (var legacyUser in legacyPasswordUsers)
{
legacyUser.IsActive = false;
legacyUser.Role = "LegacyDisabled";
}
db.SaveChanges();
}
// .env 기반 관리자 프로비저닝: 활성 관리자가 없을 때만 ADMIN_EMAIL/ADMIN_PASSWORD
// 로 SuperAdmin을 자동 생성한다(멱등 — 이미 있으면 건드리지 않는다).
var envAdminEmail = builder.Configuration["ADMIN_EMAIL"]?.Trim().ToLowerInvariant() ?? string.Empty;
var envAdminPassword = builder.Configuration["ADMIN_PASSWORD"] ?? string.Empty;
if (
envAdminEmail.Length >= 3
&& envAdminEmail.Contains('@')
&& envAdminPassword.Length >= 8
&& !db.Users.Any(u => u.IsActive)
)
{
var envAdmin = new User
{
Email = envAdminEmail,
Role = "SuperAdmin",
CreatedAt = DateTime.UtcNow,
IsActive = true,
};
envAdmin.PasswordHash = new PasswordHasher<User>().HashPassword(envAdmin, envAdminPassword);
db.Users.Add(envAdmin);
db.SaveChanges();
Console.WriteLine($"Provisioned SuperAdmin from ADMIN_EMAIL env: {envAdminEmail}");
}
// Default Model Endpoints if empty
if (!db.ModelEndpoints.Any())
@ -205,47 +380,78 @@ using (var scope = app.Services.CreateScope())
db.SaveChanges();
}
// Default Admin User seed & update password to Test1234!
var adminUser = db.Users.FirstOrDefault(u => u.Email == "admin" || u.Email == "admin@d3ro.voice");
var passwordHash = AuthService.HashPassword("Test1234!");
if (adminUser == null)
{
db.Users.AddRange(
new User
{
Email = "admin",
PasswordHash = passwordHash,
Role = "SuperAdmin",
CreatedAt = DateTime.UtcNow,
IsActive = true
},
new User
{
Email = "admin@d3ro.voice",
PasswordHash = passwordHash,
Role = "SuperAdmin",
CreatedAt = DateTime.UtcNow,
IsActive = true
}
);
db.SaveChanges();
}
else
{
adminUser.PasswordHash = passwordHash;
adminUser.Role = "SuperAdmin";
adminUser.IsActive = true;
db.SaveChanges();
}
// Administrative accounts are never seeded with repository credentials.
// Provisioning is performed through the authenticated one-time bootstrap flow.
}
app.UseSwagger();
app.UseSwaggerUI();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors("AllowAll");
app.UseCors("ConfiguredOrigins");
app.Use(async (context, next) =>
{
var isInvitePage =
context.Request.Path.StartsWithSegments("/accept-invite")
|| context.Request.Path.Equals("/accept-invite.html");
if (isInvitePage)
{
context.Response.OnStarting(() =>
{
// The invite token lives in the query string. Do not cache the page and
// prevent CDN HTML transforms (including analytics script injection).
context.Response.Headers["Cache-Control"] = "no-store, no-transform";
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; " +
"form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; " +
"object-src 'none'; script-src 'self'; style-src 'self'";
context.Response.Headers["Permissions-Policy"] =
"camera=(), microphone=(), geolocation=(), payment=(), usb=()";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
return Task.CompletedTask;
});
}
if (context.Request.Path.Equals("/accept-invite"))
{
context.Response.StatusCode = StatusCodes.Status308PermanentRedirect;
context.Response.Headers.Location = $"/accept-invite/{context.Request.QueryString}";
return;
}
await next();
});
app.UseDefaultFiles();
// Historical mobile binaries remain in the checkout for forensics only. They
// are not official releases and must never be reachable through StaticFiles.
app.Use(async (context, next) =>
{
var requestPath = context.Request.Path.Value ?? string.Empty;
var fileName = Path.GetFileName(requestPath);
var legacyMarketingAsset = requestPath.Equals("/assets/index-D7M5UQvT.js", StringComparison.OrdinalIgnoreCase)
|| requestPath.Equals("/assets/index-JlYFxlAJ.js", StringComparison.OrdinalIgnoreCase);
var mobileReleasePath = requestPath.StartsWith("/releases/", StringComparison.OrdinalIgnoreCase)
&& (fileName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase)
|| fileName.EndsWith(".aab", StringComparison.OrdinalIgnoreCase)
|| (fileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
&& (fileName.Contains("android", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("signed", StringComparison.OrdinalIgnoreCase))));
if (mobileReleasePath || legacyMarketingAsset)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
await next();
});
app.UseStaticFiles();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
@ -272,7 +478,10 @@ app.MapGet("/api/health", () => Results.Ok(new
app.MapControllers();
app.MapFallbackToFile("/accept-invite", "accept-invite.html");
// Fallback to Admin BackOffice UI index.html
app.MapFallbackToFile("/admin/{*path}", "admin/index.html");
app.Run();
public partial class Program { }