Compare commits

...

6 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
12 changed files with 427 additions and 16 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 }}'

2
.gitignore vendored
View File

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

View File

@@ -1,9 +1,22 @@
# 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.
@@ -22,15 +35,16 @@ ModeusSchedule.MSAuth — это вспомогательный HTTP-серви
## Конфигурация
| Параметр | Где задается | Назначение |
| --- | --- | --- |
| ------------------------- | ----------------------------------------- | --------------------------------------------------------------------- |
| `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. Установите .NET 9 SDK и Playwright (будет установлен автоматически при первом запуске).
1. Создайте файл `appsettings.Development.json` или задайте переменные окружения с параметрами из таблицы выше.
1. Соберите и запустите сервис:
@@ -39,6 +53,12 @@ cd src
dotnet run
```
или
```bash
docker compose -f docker-compose-dev.yml up
```
1. Выполните запрос:
```bash
@@ -53,6 +73,11 @@ curl -H "X-API-Key: <ваш ключ>" http://localhost:5000/auth/ms
- Ограничьте доступ к эндпоинту по сети (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

@@ -1,61 +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, string username, string password, string loginUrl)
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)
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)
}
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

@@ -8,6 +8,8 @@ if (string.IsNullOrWhiteSpace(builder.Configuration["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 в файле конфигурации или переменных окружения.");

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Playwright;
using ModeusSchedule.MSAuth.BrowserScripts;
@@ -13,16 +14,21 @@ public class MicrosoftAuthService(ILogger<MicrosoftAuthService> logger, IConfigu
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))
@@ -34,7 +40,10 @@ public class MicrosoftAuthService(ILogger<MicrosoftAuthService> logger, IConfigu
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
// Если запущен отладчик — запускаем окно браузера
Headless = !Debugger.IsAttached,
// При debug автоматически открывать DevTools
// Devtools = Debugger.IsAttached
});
var context = await browser.NewContextAsync(new BrowserNewContextOptions
{
@@ -45,19 +54,35 @@ public class MicrosoftAuthService(ILogger<MicrosoftAuthService> logger, IConfigu
try
{
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"]!,
_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();
@@ -114,6 +139,30 @@ public class MicrosoftAuthService(ILogger<MicrosoftAuthService> logger, IConfigu
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

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