Compare commits

...

7 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
14 changed files with 480 additions and 11 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

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

@@ -1,61 +1,162 @@
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,
Func<string?>? totpProvider = null
)
{ {
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("Нажата кнопка входа после ввода пароля.");
#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 }); 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

@@ -5,7 +5,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "http://localhost:5258", "applicationUrl": "http://localhost:5000",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.Json; using System.Text.Json;
using Microsoft.Playwright; using Microsoft.Playwright;
using ModeusSchedule.MSAuth.BrowserScripts; using ModeusSchedule.MSAuth.BrowserScripts;
@@ -13,16 +14,21 @@ public class MicrosoftAuthService(ILogger<MicrosoftAuthService> logger, IConfigu
private string? _cachedToken; private string? _cachedToken;
private DateTime _cachedAtUtc; private DateTime _cachedAtUtc;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(20); private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(20);
private readonly TotpGenerator? _totpGenerator = CreateTotpGenerator(configuration, logger);
public bool HasFreshToken => _cachedToken != null && DateTime.UtcNow - _cachedAtUtc < _cacheTtl; public bool HasFreshToken => _cachedToken != null && DateTime.UtcNow - _cachedAtUtc < _cacheTtl;
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))
@@ -34,7 +40,10 @@ public class MicrosoftAuthService(ILogger<MicrosoftAuthService> logger, IConfigu
using var playwright = await Playwright.CreateAsync(); using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions 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 var context = await browser.NewContextAsync(new BrowserNewContextOptions
{ {
@@ -45,19 +54,35 @@ 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"]!,
_totpGenerator is null ? null : () => _totpGenerator.Generate());
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();
@@ -114,6 +139,30 @@ public class MicrosoftAuthService(ILogger<MicrosoftAuthService> logger, IConfigu
EnsureLock.Release(); 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 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": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Debug",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },