Compare commits
1 Commits
0ed62ad8ed
...
ef827ba08b
| Author | SHA1 | Date | |
|---|---|---|---|
| ef827ba08b |
16
ModeusSchedule.MSAuth.sln
Normal file
16
ModeusSchedule.MSAuth.sln
Normal file
@@ -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
|
||||||
61
src/BrowserScripts/MicrosoftLoginHelper.cs
Normal file
61
src/BrowserScripts/MicrosoftLoginHelper.cs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/ModeusSchedule.MSAuth.csproj
Normal file
13
src/ModeusSchedule.MSAuth.csproj
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Playwright" Version="1.48.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
39
src/Program.cs
Normal file
39
src/Program.cs
Normal file
@@ -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<MicrosoftAuthService>();
|
||||||
|
|
||||||
|
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();
|
||||||
121
src/Services/MicrosoftAuthService.cs
Normal file
121
src/Services/MicrosoftAuthService.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
using ModeusSchedule.MSAuth.BrowserScripts;
|
||||||
|
|
||||||
|
namespace ModeusSchedule.MSAuth.Services;
|
||||||
|
|
||||||
|
public class MicrosoftAuthService(ILogger<MicrosoftAuthService> 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<string> 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<string>("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<Dictionary<string, object>>(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
|
||||||
|
{
|
||||||
|
}
|
||||||
11
src/appsettings.Development.json
Normal file
11
src/appsettings.Development.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MODEUS_URL": "https://<вуз>.modeus.org/",
|
||||||
|
"MS_USERNAME": "",
|
||||||
|
"MS_PASSWORD": ""
|
||||||
|
}
|
||||||
9
src/appsettings.json
Normal file
9
src/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user