Compare commits

...

9 Commits

Author SHA1 Message Date
196b7d0ff4 feat: Добавил поддержку TOTP
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 6m31s
2025-11-26 15:04:25 +03:00
a42cb4bfdd feat: добавил режим запуска headed режима
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 6m36s
2025-11-25 17:25:25 +03:00
5b906d6d07 docs: Обновил README
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 7s
2025-11-14 02:34:24 +03:00
8208e93711 ci: Добавил ci/cd
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 2m0s
2025-11-14 02:23:13 +03:00
858c383a9b feat: Добавил docker 2025-11-14 02:11:55 +03:00
0e7ee28791 feat: Добавил больше логирования 2025-11-14 01:50:40 +03:00
8a469625c5 Обновил документацию 2025-11-14 00:59:18 +03:00
0ed62ad8ed feat: Добавил авторизацию по api ключу 2025-11-14 00:23:37 +03:00
71bebfdf84 Upload code 2025-11-14 00:23:05 +03:00
17 changed files with 772 additions and 2 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=

View File

@@ -0,0 +1,40 @@
name: Create and publish a Docker image
on:
push:
branches: ['main', 'staging']
env:
CONTEXT: ./src
jobs:
build-and-push-image:
runs-on: ubuntu-latest
name: Publish image
container: catthehacker/ubuntu:act-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: https://github.com/docker/metadata-action@v4
with:
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}
- name: Build an image from Dockerfile
run: |
cd ${{ env.CONTEXT }} &&
docker build -t ${{ steps.meta.outputs.tags }} .
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ vars.SERVER_DOMAIN }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Push
run: |
docker push '${{ steps.meta.outputs.tags }}'

4
.gitignore vendored
View File

@@ -483,3 +483,7 @@ $RECYCLE.BIN/
# Windows shortcuts # Windows shortcuts
*.lnk *.lnk
src/appsettings.Development.json

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 serega404 Copyright (c) 2025 Sergey Karmanov
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including associated documentation files (the "Software"), to deal in the Software without restriction, including

16
ModeusSchedule.MSAuth.sln Normal file
View 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

View File

@@ -1,2 +1,83 @@
# ModeusSchedule.MSAuth # Макросервис авторизации в Modeus через Microsoft
[![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![.NET](https://img.shields.io/badge/.NET-9.0-blue.svg)](https://dotnet.microsoft.com/download/dotnet/9.0)
## Зачем нужен этот сервис
ModeusSchedule.MSAuth — это вспомогательный HTTP-сервис, который автоматизирует авторизацию в Microsoft, заходит в веб-интерфейс Modeus и извлекает свежий `id_token` (JWT). Этот токен затем может использоваться другими компонентами вашей системы для обращения к API Modeus без ручного входа пользователя. Проект устраняет необходимость хранить пользовательские cookie, а также упрощает раздачу короткоживущих токенов другим сервисам через RESTэндпоинт.
### Почему макросервис?
```dive
Total Image size: 1.4 GB
Potential wasted space: 6.5 MB
Image efficiency score: 99 %
```
> Внутри находится полноценный браузер Chromium с Playwright, что требует "значительных" ПЗУ ресурсов. Поэтому такой сервис лучше запускать отдельно от основных приложений.
## Ключевые возможности
- Браузерная авторизация через Playwright и Chromium в режиме headless.
- Кэширование токена на 20 минут, чтобы избежать лишних логинов.
- Ограничение параллельных попыток авторизации (возврат HTTP 429, если процесс уже идет).
- Опциональная защита API ключом (заголовок `X-API-Key`).
- Простой RESTэндпоинт `GET /auth/ms`, который возвращает JSON `{ "jwt": "..." }`.
## Как это работает
1. Сервис при старте проверяет, что заданы параметры подключения к Modeus и учетные данные Microsoft (`MODEUS_URL`, `MS_USERNAME`, `MS_PASSWORD`).
1. При первом запросе Playwright поднимает headless Chromium и логинится в Modeus через Microsoft IDP.
1. После успешного входа токен `id_token` извлекается из `sessionStorage`, сохраняется в памяти и отдается клиенту.
1. Пока токен свежий, новые запросы обслуживаются из кэша. Как только TTL истекает, запускается новая авторизация.
## Конфигурация
| Параметр | Где задается | Назначение |
| ------------------------- | ----------------------------------------- | --------------------------------------------------------------------- |
| `MODEUS_URL` | `appsettings.json` / переменные окружения | URL портала Modeus (например, `https://<название вуза>.modeus.org/`). |
| `MS_USERNAME` | `appsettings.json` / переменные окружения | Логин сервисной учетной записи Microsoft 365. |
| `MS_PASSWORD` | `appsettings.json` / переменные окружения | Пароль от этой учетной записи. |
| `MS_TOTP_SECRET` *(опционально)* | `appsettings.json` / переменные окружения | Секрет для генерации TOTP-кодов. |
| `API_KEY` *(опционально)* | `appsettings.json` / переменные окружения | Если задан, сервис будет требовать заголовок `X-API-Key`. |
## Быстрый старт
1. Установите .NET 9 SDK и Playwright (будет установлен автоматически при первом запуске).
1. Создайте файл `appsettings.Development.json` или задайте переменные окружения с параметрами из таблицы выше.
1. Соберите и запустите сервис:
```bash
cd src
dotnet run
```
или
```bash
docker compose -f docker-compose-dev.yml up
```
1. Выполните запрос:
```bash
curl -H "X-API-Key: <ваш ключ>" http://localhost:5000/auth/ms
```
В ответ вы получите JSON с полем `jwt`. При повторных вызовах в пределах времени жизни кэша сервис не будет заново входить в Microsoft.
## Безопасность и эксплуатация
- Используйте отдельную сервисную учетную запись Microsoft с минимально необходимыми правами.
- Ограничьте доступ к эндпоинту по сети (VPN, reverse proxy) и включите проверку `API_KEY`.
- Логи Playwright могут содержать диагностическую информацию, поэтому убедитесь, что они не раскрывают пароли.
## Идеи для улучшений
- Сброс кэша по запросу.
- Переписать на TypeScript с использованием Playwright напрямую.
## Лицензия
Проект распространяется под лицензией MIT. Подробнее в файле [`LICENSE`](LICENSE).

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

@@ -0,0 +1,15 @@
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}
- MS_TOTP_SECRET=${MS_TOTP_SECRET}
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

@@ -0,0 +1,162 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Playwright;
namespace ModeusSchedule.MSAuth.BrowserScripts;
public static class MicrosoftLoginHelper
{
public static async Task LoginMicrosoftAsync(
IPage page,
ILogger logger,
string username,
string password,
string loginUrl,
Func<string?>? totpProvider = null
)
{
logger.LogInformation("Начало входа в Microsoft. Url: {LoginUrl}, пользователь: {Username}", loginUrl, username);
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 });
var useAnotherAccount = page.Locator("div#otherTile, #otherTileText, div[data-test-id='useAnotherAccount']").First;
try
{
await Assertions.Expect(useAnotherAccount).ToBeVisibleAsync(new() { Timeout = 2000 });
logger.LogDebug("Обнаружена кнопка 'Использовать другой аккаунт'. Нажимаем.");
await useAnotherAccount.ClickAsync();
}
catch (PlaywrightException ex)
{
logger.LogDebug(ex, "Кнопка 'Использовать другой аккаунт' не появилась — пропускаем шаг.");
}
var emailInput = page.Locator("input[name='loginfmt'], input#i0116");
await emailInput.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 });
logger.LogDebug("Поле ввода email найдено, вводим логин.");
await emailInput.FillAsync(username);
var nextButton = page.Locator("#idSIButton9, input#idSIButton9");
await nextButton.ClickAsync();
logger.LogDebug("Нажата кнопка 'Далее' после ввода логина.");
var passwordInput = page.Locator("input[name='passwd'], input#i0118");
await passwordInput.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 });
logger.LogDebug("Поле ввода пароля найдено, вводим пароль.");
await passwordInput.FillAsync(password);
await nextButton.ClickAsync();
logger.LogDebug("Нажата кнопка входа после ввода пароля.");
#region MFA: выбирает TOTP вариант, запрашивает код и вводит его.
// Пытается выбрать TOTP метод на экране выбора MFA, если он отображен.
var totpOption = page.Locator("div[data-value='PhoneAppOTP'], div[data-value='OneWayOtp'], div[data-value='PhoneAppOTPRW'], div[data-value='AuthenticatorApp'], div[data-value='OneWayTotp']").First;
try
{
await Assertions.Expect(totpOption).ToBeVisibleAsync(new() { Timeout = 3_000 });
logger.LogDebug("Обнаружен выбор метода MFA. Выбираем опцию TOTP.");
await totpOption.ClickAsync();
var continueButton = page.Locator("#idSubmit_SAOTCS_ProofConfirmation, #idSIButton9, button[type='submit']").First;
if (await continueButton.IsEnabledAsync())
{
await continueButton.ClickAsync();
logger.LogDebug("Подтвердили выбор метода TOTP.");
}
}
catch (PlaywrightException ex)
{
logger.LogDebug(ex, "Экран выбора метода MFA не отображен — продолжаем без выбора.");
}
var totpInput = page.Locator("#idTxtBx_SAOTCC_OTC, input[name='otc'], input[name='code']").First;
if (totpProvider is null)
{
if (await totpInput.IsVisibleAsync())
{
logger.LogError("Обнаружен запрос на ввод TOTP, но провайдер кода не настроен. Установите переменную MS_TOTP_SECRET.");
throw new InvalidOperationException("TOTP требуется, но провайдер не настроен");
}
logger.LogDebug("TOTP не требуется или провайдер не задан.");
return;
}
try
{
await Assertions.Expect(totpInput).ToBeVisibleAsync(new() { Timeout = 10_000 });
}
catch (PlaywrightException ex)
{
logger.LogDebug(ex, "Поле ввода TOTP не найдено — MFA, вероятно, не требуется.");
return;
}
var totpCode = totpProvider();
if (string.IsNullOrWhiteSpace(totpCode))
{
logger.LogError("TOTP провайдер вернул пустое значение.");
throw new InvalidOperationException("TOTP провайдер вернул пустой код");
}
logger.LogInformation("Обнаружен экран MFA. Вводим TOTP код.");
await totpInput.FillAsync(totpCode);
var submit = page.Locator("#idSubmit_SAOTCC_Continue, #idSIButton9, button[type='submit']").First;
try
{
await submit.ClickAsync(new() { Timeout = 5_000 });
logger.LogDebug("Отправлен TOTP код.");
}
catch (PlaywrightException ex)
{
logger.LogDebug(ex, "Не удалось автоматически отправить TOTP код — возможно, форма отправилась автоматически.");
}
#endregion
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 });
logger.LogDebug("Обнаружен экран 'Остаться в системе?'.");
var noBtn = page.Locator("#idBtn_Back");
if (await noBtn.IsVisibleAsync())
{
logger.LogDebug("Нажимаем кнопку 'Нет'.");
await noBtn.ClickAsync();
}
else
{
logger.LogDebug("Кнопка 'Нет' не найдена, нажимаем 'Да'/'Далее'.");
await page.Locator("#idSIButton9").ClickAsync();
}
}
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.WaitForLoadStateAsync(LoadState.NetworkIdle);
var currentHost = new Uri(page.Url).Host;
if (Regex.IsMatch(currentHost, "login\\.(microsoftonline|live)\\.com", RegexOptions.IgnoreCase))
{
logger.LogError("Авторизация не завершена: остались на странице Microsoft Login. Текущий URL: {Url}", page.Url);
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

@@ -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>

58
src/Program.cs Normal file
View File

@@ -0,0 +1,58 @@
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);
}
Console.WriteLine($"Используется URL Modeus: {builder.Configuration["MODEUS_URL"]}");
if (string.IsNullOrWhiteSpace(builder.Configuration["MS_USERNAME"]) || string.IsNullOrWhiteSpace(builder.Configuration["MS_PASSWORD"]))
{
Console.Error.WriteLine("Ошибка: не заданы учетные данные для MicrosoftAuth. Пожалуйста, укажите MS_USERNAME и MS_PASSWORD в файле конфигурации или переменных окружения.");
Environment.Exit(1);
}
var configuredApiKey = builder.Configuration["API_KEY"];
builder.Services.AddSingleton<MicrosoftAuthService>();
var app = builder.Build();
if (!string.IsNullOrWhiteSpace(configuredApiKey))
{
app.Use(async (context, next) =>
{
if (!context.Request.Headers.TryGetValue("X-API-Key", out var providedKey) || !string.Equals(providedKey, configuredApiKey, StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("Unauthorized");
return;
}
await next();
});
}
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();

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,170 @@
using System.Diagnostics;
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);
private readonly TotpGenerator? _totpGenerator = CreateTotpGenerator(configuration, logger);
public bool HasFreshToken => _cachedToken != null && DateTime.UtcNow - _cachedAtUtc < _cacheTtl;
public async Task<string> GetJwtAsync(CancellationToken ct = default)
{
logger.LogDebug("Запрошен JWT токен. HasFreshToken=" + HasFreshToken);
await EnsureBrowsersAsync();
// Если кэш актуален — вернуть сразу
if (HasFreshToken)
{
logger.LogInformation("Возвращаем закэшированный JWT токен, возраст={AgeSeconds} сек", (DateTime.UtcNow - _cachedAtUtc).TotalSeconds);
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 = !Debugger.IsAttached,
// При debug автоматически открывать DevTools
// Devtools = Debugger.IsAttached
});
var context = await browser.NewContextAsync(new BrowserNewContextOptions
{
ViewportSize = null
});
var page = await context.NewPageAsync();
try
{
logger.LogInformation("Старт авторизации через Microsoft");
await MicrosoftLoginHelper.LoginMicrosoftAsync(
page,
logger,
configuration["MS_USERNAME"]!,
configuration["MS_PASSWORD"]!,
configuration["MODEUS_URL"]!,
_totpGenerator is null ? null : () => _totpGenerator.Generate());
var sessionStorageJson = await page.EvaluateAsync<string>("JSON.stringify(sessionStorage)");
logger.LogDebug("Пробуем извлечь id_token из sessionStorage");
string? idToken = ExtractIdToken(sessionStorageJson);
if (string.IsNullOrWhiteSpace(idToken))
{
logger.LogError("Не удалось извлечь id_token из sessionStorage");
throw new Exception("Не удалось извлечь id_token из sessionStorage");
}
// Сохраняем в кэш
_cachedToken = idToken;
_cachedAtUtc = DateTime.UtcNow;
logger.LogInformation("Успешно получили и закэшировали id_token");
return idToken;
}
catch (Exception ex) when (ex is not MicrosoftAuthInProgressException)
{
logger.LogError(ex, "Ошибка при получении JWT через Microsoft авторизацию");
throw;
}
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();
}
}
/// <summary>
/// Создает генератор TOTP из переменной окружения, если она настроена.
/// </summary>
private static TotpGenerator? CreateTotpGenerator(IConfiguration configuration, ILogger logger)
{
var secret = configuration["MS_TOTP_SECRET"];
if (string.IsNullOrWhiteSpace(secret))
{
logger.LogDebug("MS_TOTP_SECRET не задан, MFA по TOTP отключен.");
return null;
}
try
{
logger.LogInformation("Инициализация генератора TOTP для Microsoft MFA.");
return new TotpGenerator(secret);
}
catch (Exception ex)
{
logger.LogError(ex, "Не удалось инициализировать TOTP генератор. MFA не будет работать.");
return null;
}
}
}
public class MicrosoftAuthInProgressException : Exception
{
}

View File

@@ -0,0 +1,118 @@
using System.Security.Cryptography;
using System.Text;
namespace ModeusSchedule.MSAuth.Services;
/// <summary>
/// RFC 6238 compatible TOTP generator that accepts Base32 encoded secrets.
/// </summary>
public sealed class TotpGenerator
{
private static readonly DateTime Epoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private const int DefaultDigits = 6;
private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private readonly byte[] _secret;
private readonly int _digits;
private readonly TimeSpan _step;
/// <summary>
/// Creates a TOTP generator instance.
/// </summary>
/// <param name="secret">Base32 encoded shared secret.</param>
/// <param name="digits">Number of digits in the generated one-time password.</param>
/// <param name="step">Time step (defaults to 30 seconds).</param>
public TotpGenerator(string secret, int digits = DefaultDigits, TimeSpan? step = null)
{
if (string.IsNullOrWhiteSpace(secret))
throw new ArgumentException("TOTP secret must be provided", nameof(secret));
_secret = DecodeBase32(secret);
if (_secret.Length == 0)
throw new ArgumentException("TOTP secret could not be decoded", nameof(secret));
_digits = digits;
_step = step ?? TimeSpan.FromSeconds(30);
}
/// <summary>
/// Generates a one-time password for the provided point in time (or now by default).
/// </summary>
/// <param name="timestamp">Optional timestamp to generate the code for.</param>
public string Generate(DateTime? timestamp = null)
{
var counter = GetCurrentCounter(timestamp);
var counterBytes = BitConverter.GetBytes(counter);
if (BitConverter.IsLittleEndian)
Array.Reverse(counterBytes);
using var hmac = new HMACSHA1(_secret);
var hash = hmac.ComputeHash(counterBytes);
var offset = hash[^1] & 0x0F;
var binaryCode = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
var otp = binaryCode % (int)Math.Pow(10, _digits);
return otp.ToString("D" + _digits);
}
/// <summary>
/// Returns the RFC 6238 time counter value for the supplied timestamp.
/// </summary>
private long GetCurrentCounter(DateTime? timestamp)
{
var effectiveTime = timestamp?.ToUniversalTime() ?? DateTime.UtcNow;
var elapsed = effectiveTime - Epoch;
return (long)(elapsed.TotalSeconds / _step.TotalSeconds);
}
/// <summary>
/// Decodes a Base32 string into raw bytes.
/// </summary>
private static byte[] DecodeBase32(string input)
{
var sanitized = Sanitize(input);
var bitBuffer = 0;
var bitsInBuffer = 0;
var output = new List<byte>(sanitized.Length * 5 / 8);
foreach (var c in sanitized)
{
var value = Base32Alphabet.IndexOf(c);
if (value < 0)
throw new FormatException($"Symbol '{c}' is not valid for Base32");
bitBuffer = (bitBuffer << 5) | value;
bitsInBuffer += 5;
if (bitsInBuffer >= 8)
{
bitsInBuffer -= 8;
var byteValue = (byte)((bitBuffer >> bitsInBuffer) & 0xFF);
output.Add(byteValue);
}
}
return output.ToArray();
}
/// <summary>
/// Normalizes secret formatting by removing padding and separators.
/// </summary>
private static string Sanitize(string input)
{
var builder = new StringBuilder(input.Length);
foreach (var c in input)
{
if (c == '=' || c == ' ' || c == '-')
continue;
builder.Append(char.ToUpperInvariant(c));
}
return builder.ToString();
}
}

View File

@@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Warning"
}
},
"MODEUS_URL": "https://<вуз>.modeus.org/",
"MS_USERNAME": "",
"MS_PASSWORD": ""
}

9
src/appsettings.json Normal file
View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}