Compare commits

..

2 Commits

10 changed files with 135 additions and 7 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# URL сервиса Modeus
MODEUS_URL=
# Имя пользователя для авторизации в Microsoft
MS_USERNAME=
# Пароль для авторизации в Microsoft
MS_PASSWORD=
# (опционально) Ключ API для защиты эндпоинта /auth/ms
API_KEY=

2
.gitignore vendored
View File

@@ -485,3 +485,5 @@ $RECYCLE.BIN/
*.lnk *.lnk
src/appsettings.Development.json

View File

@@ -39,6 +39,12 @@ cd src
dotnet run dotnet run
``` ```
или
```bash
docker compose -f docker-compose-dev.yml up
```
1. Выполните запрос: 1. Выполните запрос:
```bash ```bash

14
docker-compose-dev.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
app:
container_name: ModeusSchedule.MSAuth
build:
context: ./src
dockerfile: Dockerfile
ports:
- '5000:8080'
environment:
- MODEUS_URL=${MODEUS_URL}
- MS_USERNAME=${MS_USERNAME}
- MS_PASSWORD=${MS_PASSWORD}
- API_KEY=${API_KEY}
restart: unless-stopped

27
src/.dockerignore Normal file
View File

@@ -0,0 +1,27 @@
**/.playwright
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
**/appsettings.Development.json

View File

@@ -1,61 +1,92 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Playwright; using Microsoft.Playwright;
namespace ModeusSchedule.MSAuth.BrowserScripts; namespace ModeusSchedule.MSAuth.BrowserScripts;
public static class MicrosoftLoginHelper public static class MicrosoftLoginHelper
{ {
public static async Task LoginMicrosoftAsync(IPage page, string username, string password, string loginUrl) public static async Task LoginMicrosoftAsync(
IPage page,
ILogger logger,
string username,
string password,
string loginUrl
)
{ {
logger.LogInformation("Начало входа в Microsoft. Url: {LoginUrl}, пользователь: {Username}", loginUrl, username);
await page.GotoAsync(loginUrl, new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded }); await page.GotoAsync(loginUrl, new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded });
logger.LogDebug("Страница логина загружена, ожидаем переход на домен Microsoft.");
await page.WaitForURLAsync(new Regex("login\\.(microsoftonline|live)\\.com", RegexOptions.IgnoreCase), new PageWaitForURLOptions { Timeout = 60_000 }); 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; var useAnotherAccount = page.Locator("div#otherTile, #otherTileText, div[data-test-id='useAnotherAccount']").First;
try try
{ {
await Assertions.Expect(useAnotherAccount).ToBeVisibleAsync(new() { Timeout = 2000 }); await Assertions.Expect(useAnotherAccount).ToBeVisibleAsync(new() { Timeout = 2000 });
logger.LogDebug("Обнаружена кнопка 'Использовать другой аккаунт'. Нажимаем.");
await useAnotherAccount.ClickAsync(); await useAnotherAccount.ClickAsync();
} }
catch (PlaywrightException) catch (PlaywrightException ex)
{ {
// Кнопка не появилась — пропускаем logger.LogDebug(ex, "Кнопка 'Использовать другой аккаунт' не появилась — пропускаем шаг.");
} }
var emailInput = page.Locator("input[name='loginfmt'], input#i0116"); var emailInput = page.Locator("input[name='loginfmt'], input#i0116");
await emailInput.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 }); await emailInput.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 });
logger.LogDebug("Поле ввода email найдено, вводим логин.");
await emailInput.FillAsync(username); await emailInput.FillAsync(username);
var nextButton = page.Locator("#idSIButton9, input#idSIButton9"); var nextButton = page.Locator("#idSIButton9, input#idSIButton9");
await nextButton.ClickAsync(); await nextButton.ClickAsync();
logger.LogDebug("Нажата кнопка 'Далее' после ввода логина.");
var passwordInput = page.Locator("input[name='passwd'], input#i0118"); var passwordInput = page.Locator("input[name='passwd'], input#i0118");
await passwordInput.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 }); await passwordInput.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 });
logger.LogDebug("Поле ввода пароля найдено, вводим пароль.");
await passwordInput.FillAsync(password); await passwordInput.FillAsync(password);
await nextButton.ClickAsync(); await nextButton.ClickAsync();
logger.LogDebug("Нажата кнопка входа после ввода пароля.");
await page.WaitForSelectorAsync("button, input[type='submit'], a", new PageWaitForSelectorOptions { Timeout = 8000 }); await page.WaitForSelectorAsync("button, input[type='submit'], a", new PageWaitForSelectorOptions { Timeout = 8000 });
var locator = page.Locator("#idSIButton9, #idBtn_Back").First; var locator = page.Locator("#idSIButton9, #idBtn_Back").First;
try try
{ {
await Assertions.Expect(locator).ToBeVisibleAsync(new() { Timeout = 3000 }); await Assertions.Expect(locator).ToBeVisibleAsync(new() { Timeout = 3000 });
logger.LogDebug("Обнаружен экран 'Остаться в системе?'.");
var noBtn = page.Locator("#idBtn_Back"); var noBtn = page.Locator("#idBtn_Back");
if (await noBtn.IsVisibleAsync()) if (await noBtn.IsVisibleAsync())
{
logger.LogDebug("Нажимаем кнопку 'Нет'.");
await noBtn.ClickAsync(); await noBtn.ClickAsync();
}
else else
{
logger.LogDebug("Кнопка 'Нет' не найдена, нажимаем 'Да'/'Далее'.");
await page.Locator("#idSIButton9").ClickAsync(); await page.Locator("#idSIButton9").ClickAsync();
} }
catch (PlaywrightException) }
catch (PlaywrightException ex)
{ {
// Кнопки не появились — пропускаем этот шаг logger.LogDebug(ex, "Экран 'Остаться в системе?' не появился — пропускаем шаг.");
} }
logger.LogInformation("Ожидаем завершения редиректов после логина.");
await page.WaitForURLAsync(url => !Regex.IsMatch(new Uri(url).Host, "login\\.(microsoftonline|live)\\.com", RegexOptions.IgnoreCase), new PageWaitForURLOptions { Timeout = 60_000 }); 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); await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var currentHost = new Uri(page.Url).Host; var currentHost = new Uri(page.Url).Host;
if (Regex.IsMatch(currentHost, "login\\.(microsoftonline|live)\\.com", RegexOptions.IgnoreCase)) if (Regex.IsMatch(currentHost, "login\\.(microsoftonline|live)\\.com", RegexOptions.IgnoreCase))
{
logger.LogError("Авторизация не завершена: остались на странице Microsoft Login. Текущий URL: {Url}", page.Url);
throw new Exception("Авторизация не завершена: остались на странице Microsoft Login"); throw new Exception("Авторизация не завершена: остались на странице Microsoft Login");
} }
logger.LogInformation("Успешный вход в Microsoft. Текущий URL: {Url}", page.Url);
}
} }

24
src/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine3.22 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY . .
RUN dotnet restore "ModeusSchedule.MSAuth.csproj"
RUN dotnet publish "ModeusSchedule.MSAuth.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
EXPOSE 8080
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends wget && \
wget -q https://github.com/PowerShell/PowerShell/releases/download/v7.5.2/powershell_7.5.2-1.deb_amd64.deb && \
apt-get install -y ./powershell_7.5.2-1.deb_amd64.deb && \
rm -f powershell_7.5.2-1.deb_amd64.deb && \
rm -rf /var/lib/apt/lists/*
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
COPY --from=build /app/publish .
RUN pwsh ./playwright.ps1 install --with-deps chromium
ENTRYPOINT ["dotnet", "ModeusSchedule.MSAuth.dll"]

View File

@@ -8,6 +8,8 @@ if (string.IsNullOrWhiteSpace(builder.Configuration["MODEUS_URL"]))
Environment.Exit(1); Environment.Exit(1);
} }
Console.WriteLine($"Используется URL Modeus: {builder.Configuration["MODEUS_URL"]}");
if (string.IsNullOrWhiteSpace(builder.Configuration["MS_USERNAME"]) || string.IsNullOrWhiteSpace(builder.Configuration["MS_PASSWORD"])) if (string.IsNullOrWhiteSpace(builder.Configuration["MS_USERNAME"]) || string.IsNullOrWhiteSpace(builder.Configuration["MS_PASSWORD"]))
{ {
Console.Error.WriteLine("Ошибка: не заданы учетные данные для MicrosoftAuth. Пожалуйста, укажите MS_USERNAME и MS_PASSWORD в файле конфигурации или переменных окружения."); Console.Error.WriteLine("Ошибка: не заданы учетные данные для MicrosoftAuth. Пожалуйста, укажите MS_USERNAME и MS_PASSWORD в файле конфигурации или переменных окружения.");

View File

@@ -18,11 +18,15 @@ public class MicrosoftAuthService(ILogger<MicrosoftAuthService> logger, IConfigu
public async Task<string> GetJwtAsync(CancellationToken ct = default) public async Task<string> GetJwtAsync(CancellationToken ct = default)
{ {
logger.LogDebug("Запрошен JWT токен. HasFreshToken=" + HasFreshToken);
await EnsureBrowsersAsync(); await EnsureBrowsersAsync();
// Если кэш актуален — вернуть сразу // Если кэш актуален — вернуть сразу
if (HasFreshToken) if (HasFreshToken)
{
logger.LogInformation("Возвращаем закэшированный JWT токен, возраст={AgeSeconds} сек", (DateTime.UtcNow - _cachedAtUtc).TotalSeconds);
return _cachedToken!; return _cachedToken!;
}
// Пытаемся единолично выполнить авторизацию // Пытаемся единолично выполнить авторизацию
if (!await FetchLock.WaitAsync(0, ct)) if (!await FetchLock.WaitAsync(0, ct))
@@ -45,19 +49,29 @@ public class MicrosoftAuthService(ILogger<MicrosoftAuthService> logger, IConfigu
try try
{ {
logger.LogInformation("Старт авторизации через Microsoft"); logger.LogInformation("Старт авторизации через Microsoft");
await MicrosoftLoginHelper.LoginMicrosoftAsync(page, configuration["MS_USERNAME"]!, configuration["MS_PASSWORD"]!, configuration["MODEUS_URL"]!); await MicrosoftLoginHelper.LoginMicrosoftAsync(page, logger, configuration["MS_USERNAME"]!, configuration["MS_PASSWORD"]!, configuration["MODEUS_URL"]!);
var sessionStorageJson = await page.EvaluateAsync<string>("JSON.stringify(sessionStorage)"); var sessionStorageJson = await page.EvaluateAsync<string>("JSON.stringify(sessionStorage)");
logger.LogDebug("Пробуем извлечь id_token из sessionStorage");
string? idToken = ExtractIdToken(sessionStorageJson); string? idToken = ExtractIdToken(sessionStorageJson);
if (string.IsNullOrWhiteSpace(idToken)) if (string.IsNullOrWhiteSpace(idToken))
{
logger.LogError("Не удалось извлечь id_token из sessionStorage");
throw new Exception("Не удалось извлечь id_token из sessionStorage"); throw new Exception("Не удалось извлечь id_token из sessionStorage");
}
// Сохраняем в кэш // Сохраняем в кэш
_cachedToken = idToken; _cachedToken = idToken;
_cachedAtUtc = DateTime.UtcNow; _cachedAtUtc = DateTime.UtcNow;
logger.LogInformation("Успешно получили и закэшировали id_token");
return idToken; return idToken;
} }
catch (Exception ex) when (ex is not MicrosoftAuthInProgressException)
{
logger.LogError(ex, "Ошибка при получении JWT через Microsoft авторизацию");
throw;
}
finally finally
{ {
await context.CloseAsync(); await context.CloseAsync();

View File

@@ -1,7 +1,7 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Debug",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },