- Adaptados los avisos al importar, tramitar, rechazar y cambiar la asignación de denuncias de otros usuarios o grupos. - Añadidos textos y botones específicos para cada operación. - Actualizadas las instrucciones, sustituyendo “modal” por “ventana de configuración de la subida”. - Aclarado el funcionamiento del asunto, grupo de destino y tercero.
453 lines
17 KiB
C#
453 lines
17 KiB
C#
using System.Globalization;
|
|
using System.Security.Claims;
|
|
using System.Text.RegularExpressions;
|
|
using GestionaDenunciasAN.Components;
|
|
using GestionaDenunciasAN.Configuration;
|
|
using GestionaDenunciasAN.Models;
|
|
using GestionaDenuncias.Shared.Models;
|
|
using GestionaDenunciasAN.Services;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.Extensions.Options;
|
|
using System.Net.Http.Headers;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.GetCultureInfo("es-ES");
|
|
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.GetCultureInfo("es-ES");
|
|
|
|
builder.Services.Configure<ApiDenunciasOptions>(builder.Configuration.GetSection(ApiDenunciasOptions.SectionName));
|
|
|
|
builder.Services.AddRazorComponents()
|
|
.AddInteractiveServerComponents(options =>
|
|
{
|
|
options.DetailedErrors = true;
|
|
options.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(180);
|
|
});
|
|
builder.Services.AddCascadingAuthenticationState();
|
|
|
|
builder.Services
|
|
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
.AddCookie(options =>
|
|
{
|
|
options.Cookie.Name = "denuncias.auth";
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
|
options.LoginPath = "/";
|
|
options.LogoutPath = "/api/auth/logout";
|
|
options.ExpireTimeSpan = TimeSpan.FromHours(8);
|
|
options.SlidingExpiration = false;
|
|
});
|
|
|
|
builder.Services.AddAuthorization();
|
|
builder.Services.AddDataProtection();
|
|
builder.Services.AddServerSideBlazor().AddCircuitOptions(option =>
|
|
{
|
|
option.DetailedErrors = true;
|
|
option.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(180);
|
|
});
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddAntiforgery();
|
|
builder.Services.AddScoped<UserState>();
|
|
builder.Services.AddSingleton<AppSessionLifetime>();
|
|
builder.Services.AddSingleton<LoginRateLimiter>();
|
|
builder.Services.AddScoped<UiBusyService>();
|
|
builder.Services.AddScoped<UiDialogService>();
|
|
builder.Services.AddScoped<ApiDenunciasClient>();
|
|
builder.Services.AddScoped<IDenunciaStore, ApiDenunciaStore>();
|
|
builder.Services.AddScoped<IInboxTrackingService, ApiInboxTrackingService>();
|
|
|
|
builder.Services.AddHttpClient(ApiDenunciasClient.HttpClientName, (sp, client) =>
|
|
{
|
|
var opts = sp.GetRequiredService<IOptions<ApiDenunciasOptions>>().Value;
|
|
client.BaseAddress = new Uri(opts.BaseUrl.TrimEnd('/') + "/");
|
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Error");
|
|
app.UseHsts();
|
|
}
|
|
|
|
app.Use(async (context, next) =>
|
|
{
|
|
context.Response.Headers.XFrameOptions = "DENY";
|
|
context.Response.Headers.XContentTypeOptions = "nosniff";
|
|
context.Response.Headers["Referrer-Policy"] = "no-referrer";
|
|
context.Response.Headers.ContentSecurityPolicy =
|
|
"default-src 'self'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data: https://cdn.jsdelivr.net; img-src 'self' data:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net";
|
|
await next();
|
|
});
|
|
|
|
if (builder.Configuration.GetValue("ForceHttpsRedirection", false))
|
|
{
|
|
app.UseHttpsRedirection();
|
|
}
|
|
app.UseStaticFiles();
|
|
app.UseRouting();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.Use(async (context, next) =>
|
|
{
|
|
var path = context.Request.Path;
|
|
var isPublic =
|
|
path == "/" ||
|
|
path.StartsWithSegments("/api") ||
|
|
path.StartsWithSegments("/_blazor") ||
|
|
path.StartsWithSegments("/_framework") ||
|
|
path.StartsWithSegments("/bootstrap") ||
|
|
path.StartsWithSegments("/Content") ||
|
|
path.StartsWithSegments("/Scripts") ||
|
|
path.StartsWithSegments("/css") ||
|
|
path.StartsWithSegments("/js") ||
|
|
path.StartsWithSegments("/favicon") ||
|
|
Path.HasExtension(path.Value);
|
|
|
|
if (context.User.Identity?.IsAuthenticated == true)
|
|
{
|
|
var username = context.User.Identity?.Name?.Trim();
|
|
var appSessionLifetime = context.RequestServices.GetRequiredService<AppSessionLifetime>();
|
|
var cookieStartupStamp = context.User.FindFirst("app_startup_stamp")?.Value;
|
|
var hasApiToken = !string.IsNullOrWhiteSpace(context.User.FindFirst(ApiDenunciasClient.AccessTokenClaim)?.Value);
|
|
var tokenIsStillValid = true;
|
|
var cookieBelongsToCurrentStartup =
|
|
!string.IsNullOrWhiteSpace(cookieStartupStamp) &&
|
|
string.Equals(cookieStartupStamp, appSessionLifetime.StartupStamp, StringComparison.Ordinal);
|
|
|
|
var tokenExpiresAt = context.User.FindFirst(ApiDenunciasClient.TokenExpiresAtClaim)?.Value;
|
|
if (!string.IsNullOrWhiteSpace(tokenExpiresAt) &&
|
|
DateTimeOffset.TryParse(tokenExpiresAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var expiresAt))
|
|
{
|
|
tokenIsStillValid = expiresAt > DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(username) || !hasApiToken || !tokenIsStillValid || !cookieBelongsToCurrentStartup)
|
|
{
|
|
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
if (!isPublic)
|
|
{
|
|
var returnUrl = $"{context.Request.Path}{context.Request.QueryString}";
|
|
context.Response.Redirect($"/?returnUrl={Uri.EscapeDataString(returnUrl)}");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isPublic || context.User.Identity?.IsAuthenticated == true)
|
|
{
|
|
await next();
|
|
return;
|
|
}
|
|
|
|
var loginReturnUrl = $"{context.Request.Path}{context.Request.QueryString}";
|
|
context.Response.Redirect($"/?returnUrl={Uri.EscapeDataString(loginReturnUrl)}");
|
|
});
|
|
app.UseAntiforgery();
|
|
|
|
var api = app.MapGroup("/api");
|
|
|
|
api.MapPost("/auth/prepare", async (
|
|
ApiLoginPrepareRequest request,
|
|
ApiDenunciasClient apiClient,
|
|
IOptions<ApiDenunciasOptions> apiOptions,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.Username) ||
|
|
string.IsNullOrWhiteSpace(request.Password))
|
|
{
|
|
return Results.Json(
|
|
new ApiError("Debes indicar usuario y contrasena."),
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
try
|
|
{
|
|
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
|
|
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
|
|
|
|
var prepared = await apiClient.PrepareLoginAsync(
|
|
request with { Username = request.Username.Trim() },
|
|
loginTimeout.Token);
|
|
|
|
return Results.Ok(prepared);
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
return Results.Json(
|
|
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl})."),
|
|
statusCode: StatusCodes.Status504GatewayTimeout);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
return Results.Json(
|
|
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
}).DisableAntiforgery();
|
|
|
|
api.MapPost("/auth/login", async (
|
|
LoginRequest request,
|
|
HttpContext httpContext,
|
|
ApiDenunciasClient apiClient,
|
|
LoginRateLimiter rateLimiter,
|
|
IOptions<ApiDenunciasOptions> apiOptions,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
|
var appSessionLifetime = httpContext.RequestServices.GetRequiredService<AppSessionLifetime>();
|
|
if (!rateLimiter.AllowAttempt(ip))
|
|
{
|
|
return Results.Json(
|
|
new ApiError("Demasiados intentos. Espera un minuto."),
|
|
statusCode: StatusCodes.Status429TooManyRequests);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Username) ||
|
|
string.IsNullOrWhiteSpace(request.Password) ||
|
|
string.IsNullOrWhiteSpace(request.Authcode))
|
|
{
|
|
return Results.Json(
|
|
new ApiError("Debes indicar usuario, contraseña y código 2FA."),
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
if (!Regex.IsMatch(request.Authcode.Trim(), @"^\d{6}$"))
|
|
{
|
|
return Results.Json(
|
|
new ApiError("El código 2FA debe tener exactamente 6 dígitos."),
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
try
|
|
{
|
|
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
|
|
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
|
|
|
|
var login = await apiClient.LoginAsync(
|
|
request with
|
|
{
|
|
Username = request.Username.Trim(),
|
|
Authcode = request.Authcode.Trim()
|
|
},
|
|
loginTimeout.Token);
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.Name, login.Username),
|
|
new("app_startup_stamp", appSessionLifetime.StartupStamp),
|
|
new(ApiDenunciasClient.AccessTokenClaim, login.AccessToken),
|
|
new(ApiDenunciasClient.TokenExpiresAtClaim, login.ExpiresAtUtc.ToString("O", CultureInfo.InvariantCulture)),
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(login.Role))
|
|
{
|
|
claims.Add(new Claim("gl_role", login.Role));
|
|
}
|
|
|
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
var principal = new ClaimsPrincipal(identity);
|
|
var authProperties = new AuthenticationProperties
|
|
{
|
|
IsPersistent = false,
|
|
AllowRefresh = true,
|
|
};
|
|
|
|
await httpContext.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
principal,
|
|
authProperties);
|
|
|
|
return Results.Ok(new LoginResponse(login.Username));
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.Json(
|
|
new ApiError(ex.Message),
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
return Results.Json(
|
|
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl}). Comprueba los logs de ApiDenuncias: probablemente esta esperando a GlobalLeaks o a una dependencia externa."),
|
|
statusCode: StatusCodes.Status504GatewayTimeout);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
return Results.Json(
|
|
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
}).DisableAntiforgery();
|
|
|
|
api.MapPost("/auth/complete", async (
|
|
ApiLoginCompleteRequest request,
|
|
HttpContext httpContext,
|
|
ApiDenunciasClient apiClient,
|
|
IOptions<ApiDenunciasOptions> apiOptions,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var appSessionLifetime = httpContext.RequestServices.GetRequiredService<AppSessionLifetime>();
|
|
|
|
if (string.IsNullOrWhiteSpace(request.PendingLoginId) ||
|
|
string.IsNullOrWhiteSpace(request.Authcode))
|
|
{
|
|
return Results.Json(
|
|
new ApiError("Debes indicar el codigo 2FA."),
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
if (!Regex.IsMatch(request.Authcode.Trim(), @"^\d{6}$"))
|
|
{
|
|
return Results.Json(
|
|
new ApiError("El codigo 2FA debe tener exactamente 6 digitos."),
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
try
|
|
{
|
|
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
|
|
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
|
|
|
|
var login = await apiClient.CompleteLoginAsync(
|
|
request with { Authcode = request.Authcode.Trim() },
|
|
loginTimeout.Token);
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.Name, login.Username),
|
|
new("app_startup_stamp", appSessionLifetime.StartupStamp),
|
|
new(ApiDenunciasClient.AccessTokenClaim, login.AccessToken),
|
|
new(ApiDenunciasClient.TokenExpiresAtClaim, login.ExpiresAtUtc.ToString("O", CultureInfo.InvariantCulture)),
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(login.Role))
|
|
{
|
|
claims.Add(new Claim("gl_role", login.Role));
|
|
}
|
|
|
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
var principal = new ClaimsPrincipal(identity);
|
|
var authProperties = new AuthenticationProperties
|
|
{
|
|
IsPersistent = false,
|
|
AllowRefresh = true,
|
|
};
|
|
|
|
await httpContext.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
principal,
|
|
authProperties);
|
|
|
|
return Results.Ok(new LoginResponse(login.Username));
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.Json(
|
|
new ApiError(ex.Message),
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
return Results.Json(
|
|
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl})."),
|
|
statusCode: StatusCodes.Status504GatewayTimeout);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
return Results.Json(
|
|
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
}).DisableAntiforgery();
|
|
|
|
api.MapPost("/auth/logout", async (
|
|
HttpContext httpContext,
|
|
ApiDenunciasClient apiClient,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (httpContext.User.Identity?.IsAuthenticated == true)
|
|
{
|
|
try
|
|
{
|
|
await apiClient.LogoutAsync(cancellationToken);
|
|
}
|
|
catch
|
|
{
|
|
// Aunque la API no responda, el usuario debe poder cerrar la sesion local.
|
|
}
|
|
}
|
|
|
|
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
return Results.Ok(new { ok = true });
|
|
}).DisableAntiforgery();
|
|
|
|
api.MapGet("/denuncias/{denunciaId:int}/ficheros/content", async (
|
|
int denunciaId,
|
|
string fileName,
|
|
HttpContext httpContext,
|
|
IHttpClientFactory httpClientFactory,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fileName))
|
|
{
|
|
return Results.BadRequest(new ApiError("Nombre de fichero obligatorio."));
|
|
}
|
|
|
|
var token = httpContext.User.FindFirst(ApiDenunciasClient.AccessTokenClaim)?.Value;
|
|
if (string.IsNullOrWhiteSpace(token))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var client = httpClientFactory.CreateClient(ApiDenunciasClient.HttpClientName);
|
|
var path = $"api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName)}";
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, path);
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
using var response = await client.SendAsync(request, cancellationToken);
|
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var message = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
return Results.Problem(message, statusCode: (int)response.StatusCode);
|
|
}
|
|
|
|
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
|
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
|
|
|
|
httpContext.Response.Headers.CacheControl = "no-store";
|
|
return Results.File(bytes, contentType, enableRangeProcessing: true);
|
|
}).RequireAuthorization();
|
|
|
|
app.MapRazorComponents<App>()
|
|
.AddInteractiveServerRenderMode();
|
|
|
|
app.Run();
|