diff --git a/ModeusSchedule.MSAuth.sln b/ModeusSchedule.MSAuth.sln new file mode 100644 index 0000000..89dde3b --- /dev/null +++ b/ModeusSchedule.MSAuth.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModeusSchedule.MSAuth.csproj", "src\ModeusSchedule.MSAuth.csproj", "{5A8B35A8-618F-4BDD-AD93-F8E1962E7F6A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5A8B35A8-618F-4BDD-AD93-F8E1962E7F6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A8B35A8-618F-4BDD-AD93-F8E1962E7F6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A8B35A8-618F-4BDD-AD93-F8E1962E7F6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A8B35A8-618F-4BDD-AD93-F8E1962E7F6A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/BrowserScripts/MicrosoftLoginHelper.cs b/src/BrowserScripts/MicrosoftLoginHelper.cs new file mode 100644 index 0000000..9a1625d --- /dev/null +++ b/src/BrowserScripts/MicrosoftLoginHelper.cs @@ -0,0 +1,61 @@ +using System.Text.RegularExpressions; +using Microsoft.Playwright; + +namespace ModeusSchedule.MSAuth.BrowserScripts; + +public static class MicrosoftLoginHelper +{ + public static async Task LoginMicrosoftAsync(IPage page, string username, string password, string loginUrl) + { + await page.GotoAsync(loginUrl, new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded }); + + await page.WaitForURLAsync(new Regex("login\\.(microsoftonline|live)\\.com", RegexOptions.IgnoreCase), new PageWaitForURLOptions { Timeout = 60_000 }); + + var useAnotherAccount = page.Locator("div#otherTile, #otherTileText, div[data-test-id='useAnotherAccount']").First; + try + { + await Assertions.Expect(useAnotherAccount).ToBeVisibleAsync(new() { Timeout = 2000 }); + await useAnotherAccount.ClickAsync(); + } + catch (PlaywrightException) + { + // Кнопка не появилась — пропускаем + } + + var emailInput = page.Locator("input[name='loginfmt'], input#i0116"); + await emailInput.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 }); + await emailInput.FillAsync(username); + + var nextButton = page.Locator("#idSIButton9, input#idSIButton9"); + await nextButton.ClickAsync(); + + var passwordInput = page.Locator("input[name='passwd'], input#i0118"); + await passwordInput.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 }); + await passwordInput.FillAsync(password); + await nextButton.ClickAsync(); + + await page.WaitForSelectorAsync("button, input[type='submit'], a", new PageWaitForSelectorOptions { Timeout = 8000 }); + + var locator = page.Locator("#idSIButton9, #idBtn_Back").First; + try + { + await Assertions.Expect(locator).ToBeVisibleAsync(new() { Timeout = 3000 }); + var noBtn = page.Locator("#idBtn_Back"); + if (await noBtn.IsVisibleAsync()) + await noBtn.ClickAsync(); + else + await page.Locator("#idSIButton9").ClickAsync(); + } + catch (PlaywrightException) + { + // Кнопки не появились — пропускаем этот шаг + } + + await page.WaitForURLAsync(url => !Regex.IsMatch(new Uri(url).Host, "login\\.(microsoftonline|live)\\.com", RegexOptions.IgnoreCase), new PageWaitForURLOptions { Timeout = 60_000 }); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var currentHost = new Uri(page.Url).Host; + if (Regex.IsMatch(currentHost, "login\\.(microsoftonline|live)\\.com", RegexOptions.IgnoreCase)) + throw new Exception("Авторизация не завершена: остались на странице Microsoft Login"); + } +} diff --git a/src/ModeusSchedule.MSAuth.csproj b/src/ModeusSchedule.MSAuth.csproj new file mode 100644 index 0000000..b043672 --- /dev/null +++ b/src/ModeusSchedule.MSAuth.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..f0a8fb0 --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,39 @@ +using ModeusSchedule.MSAuth.Services; + +var builder = WebApplication.CreateBuilder(args); + +if (string.IsNullOrWhiteSpace(builder.Configuration["MODEUS_URL"])) +{ + Console.Error.WriteLine("Ошибка: не задан URL для Modeus. Пожалуйста, укажите MODEUS_URL в файле конфигурации или переменных окружения."); + Environment.Exit(1); +} + +if (string.IsNullOrWhiteSpace(builder.Configuration["MS_USERNAME"]) || string.IsNullOrWhiteSpace(builder.Configuration["MS_PASSWORD"])) +{ + Console.Error.WriteLine("Ошибка: не заданы учетные данные для MicrosoftAuth. Пожалуйста, укажите MS_USERNAME и MS_PASSWORD в файле конфигурации или переменных окружения."); + Environment.Exit(1); +} + +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.MapGet("/auth/ms", async (MicrosoftAuthService mas, CancellationToken ct) => + { + try + { + var token = await mas.GetJwtAsync(ct); + return Results.Json(new { jwt = token }); + } + catch (MicrosoftAuthInProgressException) + { + return Results.StatusCode(StatusCodes.Status429TooManyRequests); + } + catch (Exception ex) + { + return Results.Problem(ex.Message, statusCode: 500); + } + }) + .WithName("GetMsJwt"); + +app.Run(); \ No newline at end of file diff --git a/src/Properties/launchSettings.json b/src/Properties/launchSettings.json new file mode 100644 index 0000000..522d3d7 --- /dev/null +++ b/src/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5258", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/MicrosoftAuthService.cs b/src/Services/MicrosoftAuthService.cs new file mode 100644 index 0000000..f4ea435 --- /dev/null +++ b/src/Services/MicrosoftAuthService.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using Microsoft.Playwright; +using ModeusSchedule.MSAuth.BrowserScripts; + +namespace ModeusSchedule.MSAuth.Services; + +public class MicrosoftAuthService(ILogger logger, IConfiguration configuration) +{ + private static bool _browsersEnsured; + private static readonly SemaphoreSlim EnsureLock = new(1, 1); + private static readonly SemaphoreSlim FetchLock = new(1, 1); + + private string? _cachedToken; + private DateTime _cachedAtUtc; + private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(20); + + public bool HasFreshToken => _cachedToken != null && DateTime.UtcNow - _cachedAtUtc < _cacheTtl; + + public async Task GetJwtAsync(CancellationToken ct = default) + { + await EnsureBrowsersAsync(); + + // Если кэш актуален — вернуть сразу + if (HasFreshToken) + return _cachedToken!; + + // Пытаемся единолично выполнить авторизацию + if (!await FetchLock.WaitAsync(0, ct)) + { + // Если кто-то уже выполняет, а кэша нет — просим повторить позже (429) + throw new MicrosoftAuthInProgressException(); + } + + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true + }); + var context = await browser.NewContextAsync(new BrowserNewContextOptions + { + ViewportSize = null + }); + var page = await context.NewPageAsync(); + + try + { + logger.LogInformation("Старт авторизации через Microsoft"); + await MicrosoftLoginHelper.LoginMicrosoftAsync(page, configuration["MS_USERNAME"]!, configuration["MS_PASSWORD"]!, configuration["MODEUS_URL"]!); + + var sessionStorageJson = await page.EvaluateAsync("JSON.stringify(sessionStorage)"); + + string? idToken = ExtractIdToken(sessionStorageJson); + if (string.IsNullOrWhiteSpace(idToken)) + throw new Exception("Не удалось извлечь id_token из sessionStorage"); + + // Сохраняем в кэш + _cachedToken = idToken; + _cachedAtUtc = DateTime.UtcNow; + return idToken; + } + finally + { + await context.CloseAsync(); + await browser.CloseAsync(); + + if (FetchLock.CurrentCount == 0) FetchLock.Release(); + } + } + + private static string? ExtractIdToken(string sessionStorageJson) + { + try + { + var dict = JsonSerializer.Deserialize>(sessionStorageJson); + if (dict is null) return null; + + var oidcKey = dict.Keys.FirstOrDefault(k => k.StartsWith("oidc.user:")); + if (oidcKey is null) return null; + + var oidcValueJson = dict[oidcKey].ToString(); + if (string.IsNullOrWhiteSpace(oidcValueJson)) return null; + + using var doc = JsonDocument.Parse(oidcValueJson); + if (doc.RootElement.TryGetProperty("id_token", out var tokenEl)) + return tokenEl.GetString(); + } + catch + { + // ignore and return null + } + return null; + } + + private static async Task EnsureBrowsersAsync() + { + if (_browsersEnsured) return; + await EnsureLock.WaitAsync(); + try + { + if (_browsersEnsured) return; + try + { + // Устанавливаем Chromium, если не установлен + Microsoft.Playwright.Program.Main(["install", "chromium"]); + } + catch + { + // Игнорируем, если установка уже произведена или нет прав — попробуем дальше запустить браузер + } + _browsersEnsured = true; + } + finally + { + EnsureLock.Release(); + } + } +} + +public class MicrosoftAuthInProgressException : Exception +{ +} diff --git a/src/appsettings.Development.json b/src/appsettings.Development.json new file mode 100644 index 0000000..d838ee7 --- /dev/null +++ b/src/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "MODEUS_URL": "https://<вуз>.modeus.org/", + "MS_USERNAME": "", + "MS_PASSWORD": "" +} diff --git a/src/appsettings.json b/src/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}