feat: Добавил поддержку TOTP
Some checks failed
Create and publish a Docker image / Publish image (push) Has been cancelled
Some checks failed
Create and publish a Docker image / Publish image (push) Has been cancelled
This commit is contained in:
@@ -35,15 +35,16 @@ Image efficiency score: 99 %
|
|||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
| Параметр | Где задается | Назначение |
|
| Параметр | Где задается | Назначение |
|
||||||
| --- | --- | --- |
|
| ------------------------- | ----------------------------------------- | --------------------------------------------------------------------- |
|
||||||
| `MODEUS_URL` | `appsettings.json` / переменные окружения | URL портала Modeus (например, `https://<название вуза>.modeus.org/`). |
|
| `MODEUS_URL` | `appsettings.json` / переменные окружения | URL портала Modeus (например, `https://<название вуза>.modeus.org/`). |
|
||||||
| `MS_USERNAME` | `appsettings.json` / переменные окружения | Логин сервисной учетной записи Microsoft 365. |
|
| `MS_USERNAME` | `appsettings.json` / переменные окружения | Логин сервисной учетной записи Microsoft 365. |
|
||||||
| `MS_PASSWORD` | `appsettings.json` / переменные окружения | Пароль от этой учетной записи. |
|
| `MS_PASSWORD` | `appsettings.json` / переменные окружения | Пароль от этой учетной записи. |
|
||||||
|
| `MS_TOTP_SECRET` *(опционально)* | `appsettings.json` / переменные окружения | Секрет для генерации TOTP-кодов. |
|
||||||
| `API_KEY` *(опционально)* | `appsettings.json` / переменные окружения | Если задан, сервис будет требовать заголовок `X-API-Key`. |
|
| `API_KEY` *(опционально)* | `appsettings.json` / переменные окружения | Если задан, сервис будет требовать заголовок `X-API-Key`. |
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
1. Установите .NET 9 SDK и Playwright (будет поставлен автоматически при первом запуске).
|
1. Установите .NET 9 SDK и Playwright (будет установлен автоматически при первом запуске).
|
||||||
1. Создайте файл `appsettings.Development.json` или задайте переменные окружения с параметрами из таблицы выше.
|
1. Создайте файл `appsettings.Development.json` или задайте переменные окружения с параметрами из таблицы выше.
|
||||||
1. Соберите и запустите сервис:
|
1. Соберите и запустите сервис:
|
||||||
|
|
||||||
@@ -76,7 +77,6 @@ curl -H "X-API-Key: <ваш ключ>" http://localhost:5000/auth/ms
|
|||||||
|
|
||||||
- Сброс кэша по запросу.
|
- Сброс кэша по запросу.
|
||||||
- Переписать на TypeScript с использованием Playwright напрямую.
|
- Переписать на TypeScript с использованием Playwright напрямую.
|
||||||
- Добавить поддержку MFA (но как получать ключи?).
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ public static class MicrosoftLoginHelper
|
|||||||
ILogger logger,
|
ILogger logger,
|
||||||
string username,
|
string username,
|
||||||
string password,
|
string password,
|
||||||
string loginUrl
|
string loginUrl,
|
||||||
|
Func<string?>? totpProvider = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Начало входа в Microsoft. Url: {LoginUrl}, пользователь: {Username}", loginUrl, username);
|
logger.LogInformation("Начало входа в Microsoft. Url: {LoginUrl}, пользователь: {Username}", loginUrl, username);
|
||||||
@@ -52,6 +53,75 @@ public static class MicrosoftLoginHelper
|
|||||||
|
|
||||||
logger.LogDebug("Нажата кнопка входа после ввода пароля.");
|
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;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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;
|
||||||
|
|
||||||
@@ -53,7 +54,13 @@ public class MicrosoftAuthService(ILogger<MicrosoftAuthService> logger, IConfigu
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
logger.LogInformation("Старт авторизации через Microsoft");
|
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<string>("JSON.stringify(sessionStorage)");
|
var sessionStorageJson = await page.EvaluateAsync<string>("JSON.stringify(sessionStorage)");
|
||||||
|
|
||||||
@@ -132,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
|
||||||
|
|||||||
118
src/Services/TotpGenerator.cs
Normal file
118
src/Services/TotpGenerator.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user