Добавил получение guid пользователя по фио через токен
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 3m54s

This commit is contained in:
2025-09-08 16:52:14 +03:00
parent dbfcaac425
commit 82e7d92584
12 changed files with 164 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
namespace SfeduSchedule.Auth;
public static class ApiKeyAuthenticationDefaults
{
public const string Scheme = "ApiKey";
public const string HeaderName = "X-Api-Key";
}

View File

@@ -0,0 +1,72 @@
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace SfeduSchedule.Auth;
public class ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration configuration)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Ожидаем ключ из ENV
var expectedKey = configuration["API_KEY"];
if (string.IsNullOrEmpty(expectedKey))
return Task.FromResult(AuthenticateResult.Fail("API key is not configured."));
// Ищем ключ в заголовке X-Api-Key (и опционально в query ?api_key=)
string? providedKey = null;
if (Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var values))
providedKey = values.FirstOrDefault();
if (string.IsNullOrEmpty(providedKey) &&
Request.Query.TryGetValue("api_key", out var qv))
providedKey = qv.FirstOrDefault();
if (string.IsNullOrEmpty(providedKey))
// Нет ключа — позволь другим схемам (если есть) продолжить; при [Authorize] будет 401
return Task.FromResult(AuthenticateResult.NoResult());
if (!ConstantTimeEquals(providedKey!, expectedKey))
return Task.FromResult(AuthenticateResult.Fail("Invalid API key."));
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "api-key"),
new Claim(ClaimTypes.Name, "api-key-user"),
};
var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationDefaults.Scheme);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationDefaults.Scheme);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.Headers["WWW-Authenticate"] =
$"{ApiKeyAuthenticationDefaults.Scheme} realm=\"api\", header=\"{ApiKeyAuthenticationDefaults.HeaderName}\"";
Response.StatusCode = 401;
return Task.CompletedTask;
}
private static bool ConstantTimeEquals(string a, string b)
{
var aBytes = Encoding.UTF8.GetBytes(a);
var bBytes = Encoding.UTF8.GetBytes(b);
if (aBytes.Length != bBytes.Length)
return false;
return CryptographicOperations.FixedTimeEquals(aBytes, bBytes);
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using Microsoft.AspNetCore.Authorization;
namespace SfeduSchedule.Auth
{
public class SwaggerAuthorizeOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var hasAuthorize = context.MethodInfo.GetCustomAttributes(true).OfType<AuthorizeAttribute>().Any() ||
context.MethodInfo.DeclaringType?.GetCustomAttributes(true).OfType<AuthorizeAttribute>().Any() == true;
if (hasAuthorize)
{
operation.Security ??= new List<OpenApiSecurityRequirement>();
operation.Security.Add(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = ApiKeyAuthenticationDefaults.Scheme
}
},
new List<string>()
}
});
}
}
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using SfeduSchedule.Services; using SfeduSchedule.Services;
@@ -41,5 +42,25 @@ namespace SfeduSchedule.Controllers
var schedule = await modeusService.GetScheduleAsync(request); var schedule = await modeusService.GetScheduleAsync(request);
return Ok(schedule); return Ok(schedule);
} }
/// <summary>
/// Получить GUID пользователя по полному имени. (требуется авторизация)
/// </summary>
/// <param name="fullname">Полное имя пользователя.</param>
/// <returns>GUID пользователя.</returns>
/// <response code="200">Возвращает GUID пользователя</response>
/// <response code="404">Пользователь не найден</response>
/// <response code="401">Неавторизованный</response>
[HttpGet]
[Authorize(AuthenticationSchemes = "ApiKey")]
[Route("GetGuid")]
public async Task<IActionResult> GetGuid(string fullname)
{
var guid = await modeusService.GetGuidAsync(fullname);
if (string.IsNullOrEmpty(guid))
return NotFound();
return Ok(guid);
}
} }
} }

View File

@@ -6,7 +6,7 @@ namespace SfeduSchedule.Controllers
{ {
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize] [Authorize(AuthenticationSchemes = "OpenIdConnect")]
public class SfeduController : ControllerBase public class SfeduController : ControllerBase
{ {
private readonly ModeusService _modeusService; private readonly ModeusService _modeusService;
@@ -20,7 +20,8 @@ namespace SfeduSchedule.Controllers
/// <returns>Строка GUID пользователя или редирект на указанный URI.</returns> /// <returns>Строка GUID пользователя или редирект на указанный URI.</returns>
/// <response code="200">Возвращает GUID пользователя</response> /// <response code="200">Возвращает GUID пользователя</response>
/// <response code="302">Редирект на указанный URI</response> /// <response code="302">Редирект на указанный URI</response>
/// <response code="500">Ошибка при получении имени пользователя или GUID</response> /// <response code="404">Пользователь не найден</response>
/// <response code="401">Неавторизованный</response>
[HttpGet] [HttpGet]
[Route("guid")] [Route("guid")]
public async Task<IActionResult> Get([FromQuery] string? redirectUri) public async Task<IActionResult> Get([FromQuery] string? redirectUri)
@@ -31,7 +32,7 @@ namespace SfeduSchedule.Controllers
var guid = await _modeusService.GetGuidAsync(name); var guid = await _modeusService.GetGuidAsync(name);
if (string.IsNullOrEmpty(guid)) if (string.IsNullOrEmpty(guid))
return StatusCode(StatusCodes.Status500InternalServerError); return NotFound();
if (!string.IsNullOrEmpty(redirectUri)) if (!string.IsNullOrEmpty(redirectUri))
{ {

View File

@@ -2,6 +2,8 @@ using System.Reflection;
using System.Runtime.Loader; using System.Runtime.Loader;
using SfeduSchedule.Plugin.Abstractions; using SfeduSchedule.Plugin.Abstractions;
namespace SfeduSchedule;
public sealed record LoadedPlugin(IPlugin Instance, Assembly Assembly, PluginLoadContext Context); public sealed record LoadedPlugin(IPlugin Instance, Assembly Assembly, PluginLoadContext Context);
public static class PluginLoader public static class PluginLoader
@@ -48,7 +50,7 @@ public sealed class PluginLoadContext : AssemblyLoadContext
} }
// Разрешаем управляемые зависимости плагина из его папки. // Разрешаем управляемые зависимости плагина из его папки.
// Возвращаем null, чтобы отдать решение в Default ALC для общих сборок (например, Plugin.Abstractions). // Возвращаем null, чтобы отдать решение в Default ALC для общих сборок (например, SfeduSchedule.Plugin.Abstractions).
protected override Assembly? Load(AssemblyName assemblyName) protected override Assembly? Load(AssemblyName assemblyName)
{ {
var path = _resolver.ResolveAssemblyToPath(assemblyName); var path = _resolver.ResolveAssemblyToPath(assemblyName);

View File

@@ -1,4 +1,6 @@
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web; using Microsoft.Identity.Web;
using Quartz; using Quartz;
using SfeduSchedule; using SfeduSchedule;
@@ -7,6 +9,7 @@ using SfeduSchedule.Services;
using X.Extensions.Logging.Telegram.Extensions; using X.Extensions.Logging.Telegram.Extensions;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.ApplicationParts;
using SfeduSchedule.Auth;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -50,6 +53,12 @@ if (!string.IsNullOrEmpty(tgChatId) && !string.IsNullOrEmpty(tgToken))
var mvcBuilder = builder.Services.AddControllers(); var mvcBuilder = builder.Services.AddControllers();
builder.Services.AddHttpClient<ModeusService>(); builder.Services.AddHttpClient<ModeusService>();
builder.Services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationDefaults.Scheme, _ => { });
builder.Services.AddAuthorization();
builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration); builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration);
// Загружаем плагины (изолированно), даём им зарегистрировать DI и подключаем контроллеры // Загружаем плагины (изолированно), даём им зарегистрировать DI и подключаем контроллеры
@@ -63,7 +72,6 @@ foreach (var p in loaded)
mvcBuilder.PartManager.ApplicationParts.Add(new AssemblyPart(p.Assembly)); mvcBuilder.PartManager.ApplicationParts.Add(new AssemblyPart(p.Assembly));
} }
var jobKey = new JobKey("UpdateJWTJob"); var jobKey = new JobKey("UpdateJWTJob");
if (string.IsNullOrEmpty(preinstalledJwtToken)) if (string.IsNullOrEmpty(preinstalledJwtToken))
@@ -88,6 +96,17 @@ builder.Services.AddSwaggerGen(options =>
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
options.IncludeXmlComments(xmlPath); options.IncludeXmlComments(xmlPath);
// Добавляем только схему авторизации по ApiKey
options.AddSecurityDefinition(ApiKeyAuthenticationDefaults.Scheme, new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Description = $"Api Key needed to access the endpoints. {ApiKeyAuthenticationDefaults.HeaderName}: Your_API_Key",
Name = ApiKeyAuthenticationDefaults.HeaderName,
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
Scheme = ApiKeyAuthenticationDefaults.Scheme
});
options.OperationFilter<SwaggerAuthorizeOperationFilter>();
}); });
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
@@ -169,14 +188,10 @@ if (string.IsNullOrEmpty(preinstalledJwtToken))
await scheduler.TriggerJob(jobKey); await scheduler.TriggerJob(jobKey);
} }
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseStaticFiles(); app.UseStaticFiles();

View File

@@ -10,7 +10,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8"/>
<PackageReference Include="Microsoft.Identity.Web" Version="3.14.0" /> <PackageReference Include="Microsoft.Identity.Web" Version="3.14.0" />
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" /> <PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.0" /> <PackageReference Include="Quartz.AspNetCore" Version="3.15.0" />

View File

@@ -15,6 +15,7 @@ services:
- MS_PASSWORD=${MS_PASSWORD} - MS_PASSWORD=${MS_PASSWORD}
- TG_CHAT_ID=${TG_CHAT_ID} - TG_CHAT_ID=${TG_CHAT_ID}
- TG_TOKEN=${TG_TOKEN} - TG_TOKEN=${TG_TOKEN}
- API_KEY=${API_KEY}
# - TOKEN=${TOKEN} # - TOKEN=${TOKEN}
volumes: volumes:
- data:/app/data - data:/app/data

View File

@@ -15,6 +15,7 @@ services:
- MS_PASSWORD=${MS_PASSWORD} - MS_PASSWORD=${MS_PASSWORD}
- TG_CHAT_ID=${TG_CHAT_ID} - TG_CHAT_ID=${TG_CHAT_ID}
- TG_TOKEN=${TG_TOKEN} - TG_TOKEN=${TG_TOKEN}
- API_KEY=${API_KEY}
# - TOKEN=${TOKEN} # - TOKEN=${TOKEN}
volumes: volumes:
- ./data:/app/data - ./data:/app/data