diff --git a/SfeduSchedule/Auth/ApiKeyAuthenticationDefaults.cs b/SfeduSchedule/Auth/ApiKeyAuthenticationDefaults.cs new file mode 100644 index 0000000..d612f70 --- /dev/null +++ b/SfeduSchedule/Auth/ApiKeyAuthenticationDefaults.cs @@ -0,0 +1,7 @@ +namespace SfeduSchedule.Auth; + +public static class ApiKeyAuthenticationDefaults +{ + public const string Scheme = "ApiKey"; + public const string HeaderName = "X-Api-Key"; +} \ No newline at end of file diff --git a/SfeduSchedule/Auth/ApiKeyAuthenticationHandler.cs b/SfeduSchedule/Auth/ApiKeyAuthenticationHandler.cs new file mode 100644 index 0000000..944b101 --- /dev/null +++ b/SfeduSchedule/Auth/ApiKeyAuthenticationHandler.cs @@ -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 options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration configuration) + : AuthenticationHandler(options, logger, encoder) +{ + protected override Task 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); + } +} \ No newline at end of file diff --git a/SfeduSchedule/Auth/SwaggerAuthorizeOperationFilter.cs b/SfeduSchedule/Auth/SwaggerAuthorizeOperationFilter.cs new file mode 100644 index 0000000..b3738e1 --- /dev/null +++ b/SfeduSchedule/Auth/SwaggerAuthorizeOperationFilter.cs @@ -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().Any() || + context.MethodInfo.DeclaringType?.GetCustomAttributes(true).OfType().Any() == true; + if (hasAuthorize) + { + operation.Security ??= new List(); + operation.Security.Add(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = ApiKeyAuthenticationDefaults.Scheme + } + }, + new List() + } + }); + } + } + } +} diff --git a/SfeduSchedule/Controllers/ScheduleController.cs b/SfeduSchedule/Controllers/ScheduleController.cs index 7c31b96..01a4714 100644 --- a/SfeduSchedule/Controllers/ScheduleController.cs +++ b/SfeduSchedule/Controllers/ScheduleController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using SfeduSchedule.Services; @@ -41,5 +42,25 @@ namespace SfeduSchedule.Controllers var schedule = await modeusService.GetScheduleAsync(request); return Ok(schedule); } + + /// + /// Получить GUID пользователя по полному имени. (требуется авторизация) + /// + /// Полное имя пользователя. + /// GUID пользователя. + /// Возвращает GUID пользователя + /// Пользователь не найден + /// Неавторизованный + [HttpGet] + [Authorize(AuthenticationSchemes = "ApiKey")] + [Route("GetGuid")] + public async Task GetGuid(string fullname) + { + var guid = await modeusService.GetGuidAsync(fullname); + if (string.IsNullOrEmpty(guid)) + return NotFound(); + + return Ok(guid); + } } } diff --git a/SfeduSchedule/Controllers/SfeduController.cs b/SfeduSchedule/Controllers/SfeduController.cs index 8a1fcf8..e47c62c 100644 --- a/SfeduSchedule/Controllers/SfeduController.cs +++ b/SfeduSchedule/Controllers/SfeduController.cs @@ -6,7 +6,7 @@ namespace SfeduSchedule.Controllers { [ApiController] [Route("api/[controller]")] - [Authorize] + [Authorize(AuthenticationSchemes = "OpenIdConnect")] public class SfeduController : ControllerBase { private readonly ModeusService _modeusService; @@ -20,7 +20,8 @@ namespace SfeduSchedule.Controllers /// Строка GUID пользователя или редирект на указанный URI. /// Возвращает GUID пользователя /// Редирект на указанный URI - /// Ошибка при получении имени пользователя или GUID + /// Пользователь не найден + /// Неавторизованный [HttpGet] [Route("guid")] public async Task Get([FromQuery] string? redirectUri) @@ -31,7 +32,7 @@ namespace SfeduSchedule.Controllers var guid = await _modeusService.GetGuidAsync(name); if (string.IsNullOrEmpty(guid)) - return StatusCode(StatusCodes.Status500InternalServerError); + return NotFound(); if (!string.IsNullOrEmpty(redirectUri)) { diff --git a/SfeduSchedule/ModeusScheduleRequestDTO.cs b/SfeduSchedule/DTO/ModeusScheduleRequestDTO.cs similarity index 100% rename from SfeduSchedule/ModeusScheduleRequestDTO.cs rename to SfeduSchedule/DTO/ModeusScheduleRequestDTO.cs diff --git a/SfeduSchedule/MicrosoftLoginHelper.cs b/SfeduSchedule/Playwright/MicrosoftLoginHelper.cs similarity index 100% rename from SfeduSchedule/MicrosoftLoginHelper.cs rename to SfeduSchedule/Playwright/MicrosoftLoginHelper.cs diff --git a/SfeduSchedule/PluginLoader.cs b/SfeduSchedule/PluginLoader.cs index f46a58c..e5ac290 100644 --- a/SfeduSchedule/PluginLoader.cs +++ b/SfeduSchedule/PluginLoader.cs @@ -2,6 +2,8 @@ using System.Reflection; using System.Runtime.Loader; using SfeduSchedule.Plugin.Abstractions; +namespace SfeduSchedule; + public sealed record LoadedPlugin(IPlugin Instance, Assembly Assembly, PluginLoadContext Context); 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) { var path = _resolver.ResolveAssemblyToPath(assemblyName); diff --git a/SfeduSchedule/Program.cs b/SfeduSchedule/Program.cs index 21d409f..05c2913 100644 --- a/SfeduSchedule/Program.cs +++ b/SfeduSchedule/Program.cs @@ -1,4 +1,6 @@ using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Identity.Web; using Quartz; using SfeduSchedule; @@ -7,6 +9,7 @@ using SfeduSchedule.Services; using X.Extensions.Logging.Telegram.Extensions; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc.ApplicationParts; +using SfeduSchedule.Auth; var builder = WebApplication.CreateBuilder(args); @@ -50,6 +53,12 @@ if (!string.IsNullOrEmpty(tgChatId) && !string.IsNullOrEmpty(tgToken)) var mvcBuilder = builder.Services.AddControllers(); builder.Services.AddHttpClient(); +builder.Services.AddAuthentication() + .AddScheme( + ApiKeyAuthenticationDefaults.Scheme, _ => { }); + +builder.Services.AddAuthorization(); + builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration); // Загружаем плагины (изолированно), даём им зарегистрировать DI и подключаем контроллеры @@ -63,7 +72,6 @@ foreach (var p in loaded) mvcBuilder.PartManager.ApplicationParts.Add(new AssemblyPart(p.Assembly)); } - var jobKey = new JobKey("UpdateJWTJob"); if (string.IsNullOrEmpty(preinstalledJwtToken)) @@ -88,6 +96,17 @@ builder.Services.AddSwaggerGen(options => var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); 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(); }); builder.Services.AddRateLimiter(options => @@ -169,14 +188,10 @@ if (string.IsNullOrEmpty(preinstalledJwtToken)) await scheduler.TriggerJob(jobKey); } - -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - app.UseSwagger(); app.UseSwaggerUI(); + +app.UseAuthentication(); app.UseAuthorization(); app.UseStaticFiles(); diff --git a/SfeduSchedule/SfeduSchedule.csproj b/SfeduSchedule/SfeduSchedule.csproj index 3bc8305..3acf5e8 100644 --- a/SfeduSchedule/SfeduSchedule.csproj +++ b/SfeduSchedule/SfeduSchedule.csproj @@ -10,7 +10,6 @@ - diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 33b278d..f092400 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -15,6 +15,7 @@ services: - MS_PASSWORD=${MS_PASSWORD} - TG_CHAT_ID=${TG_CHAT_ID} - TG_TOKEN=${TG_TOKEN} + - API_KEY=${API_KEY} # - TOKEN=${TOKEN} volumes: - data:/app/data diff --git a/docker-compose-test.yml b/docker-compose-test.yml index b1a5944..3c0fb34 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -15,6 +15,7 @@ services: - MS_PASSWORD=${MS_PASSWORD} - TG_CHAT_ID=${TG_CHAT_ID} - TG_TOKEN=${TG_TOKEN} + - API_KEY=${API_KEY} # - TOKEN=${TOKEN} volumes: - ./data:/app/data