From 196b7d0ff41d3c0290a35b054f665f622fb83532 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Wed, 26 Nov 2025 15:01:22 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D1=83?= =?UTF-8?q?=20TOTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 +-- docker-compose-dev.yml | 1 + src/BrowserScripts/MicrosoftLoginHelper.cs | 72 ++++++++++++- src/Services/MicrosoftAuthService.cs | 33 +++++- src/Services/TotpGenerator.cs | 118 +++++++++++++++++++++ 5 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 src/Services/TotpGenerator.cs diff --git a/README.md b/README.md index 1faa912..76c041f 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,17 @@ Image efficiency score: 99 % ## Конфигурация -| Параметр | Где задается | Назначение | -| --- | --- | --- | -| `MODEUS_URL` | `appsettings.json` / переменные окружения | URL портала Modeus (например, `https://<название вуза>.modeus.org/`). | -| `MS_USERNAME` | `appsettings.json` / переменные окружения | Логин сервисной учетной записи Microsoft 365. | -| `MS_PASSWORD` | `appsettings.json` / переменные окружения | Пароль от этой учетной записи. | -| `API_KEY` *(опционально)* | `appsettings.json` / переменные окружения | Если задан, сервис будет требовать заголовок `X-API-Key`. | +| Параметр | Где задается | Назначение | +| ------------------------- | ----------------------------------------- | --------------------------------------------------------------------- | +| `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. Соберите и запустите сервис: @@ -76,7 +77,6 @@ curl -H "X-API-Key: <ваш ключ>" http://localhost:5000/auth/ms - Сброс кэша по запросу. - Переписать на TypeScript с использованием Playwright напрямую. -- Добавить поддержку MFA (но как получать ключи?). ## Лицензия diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 87281e9..2c22ede 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -11,4 +11,5 @@ services: - MS_USERNAME=${MS_USERNAME} - MS_PASSWORD=${MS_PASSWORD} - API_KEY=${API_KEY} + - MS_TOTP_SECRET=${MS_TOTP_SECRET} restart: unless-stopped diff --git a/src/BrowserScripts/MicrosoftLoginHelper.cs b/src/BrowserScripts/MicrosoftLoginHelper.cs index bdfdd8b..d0f1bba 100644 --- a/src/BrowserScripts/MicrosoftLoginHelper.cs +++ b/src/BrowserScripts/MicrosoftLoginHelper.cs @@ -11,7 +11,8 @@ public static class MicrosoftLoginHelper ILogger logger, string username, string password, - string loginUrl + string loginUrl, + Func? totpProvider = null ) { logger.LogInformation("Начало входа в Microsoft. Url: {LoginUrl}, пользователь: {Username}", loginUrl, username); @@ -52,6 +53,75 @@ public static class MicrosoftLoginHelper 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; diff --git a/src/Services/MicrosoftAuthService.cs b/src/Services/MicrosoftAuthService.cs index 739eaac..39920b1 100644 --- a/src/Services/MicrosoftAuthService.cs +++ b/src/Services/MicrosoftAuthService.cs @@ -14,6 +14,7 @@ public class MicrosoftAuthService(ILogger 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; @@ -53,7 +54,13 @@ public class MicrosoftAuthService(ILogger logger, IConfigu try { logger.LogInformation("Старт авторизации через Microsoft"); - await MicrosoftLoginHelper.LoginMicrosoftAsync(page, logger, 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("JSON.stringify(sessionStorage)"); @@ -132,6 +139,30 @@ public class MicrosoftAuthService(ILogger logger, IConfigu EnsureLock.Release(); } } + + /// + /// Создает генератор TOTP из переменной окружения, если она настроена. + /// + 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 diff --git a/src/Services/TotpGenerator.cs b/src/Services/TotpGenerator.cs new file mode 100644 index 0000000..2ce3206 --- /dev/null +++ b/src/Services/TotpGenerator.cs @@ -0,0 +1,118 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ModeusSchedule.MSAuth.Services; + +/// +/// RFC 6238 compatible TOTP generator that accepts Base32 encoded secrets. +/// +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; + + /// + /// Creates a TOTP generator instance. + /// + /// Base32 encoded shared secret. + /// Number of digits in the generated one-time password. + /// Time step (defaults to 30 seconds). + 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); + } + + /// + /// Generates a one-time password for the provided point in time (or now by default). + /// + /// Optional timestamp to generate the code for. + 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); + } + + /// + /// Returns the RFC 6238 time counter value for the supplied timestamp. + /// + private long GetCurrentCounter(DateTime? timestamp) + { + var effectiveTime = timestamp?.ToUniversalTime() ?? DateTime.UtcNow; + var elapsed = effectiveTime - Epoch; + return (long)(elapsed.TotalSeconds / _step.TotalSeconds); + } + + /// + /// Decodes a Base32 string into raw bytes. + /// + private static byte[] DecodeBase32(string input) + { + var sanitized = Sanitize(input); + var bitBuffer = 0; + var bitsInBuffer = 0; + var output = new List(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(); + } + + /// + /// Normalizes secret formatting by removing padding and separators. + /// + 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(); + } +}