Compare commits
1 Commits
main
...
ef827ba08b
| Author | SHA1 | Date | |
|---|---|---|---|
| ef827ba08b |
@@ -1,8 +0,0 @@
|
|||||||
# URL сервиса Modeus
|
|
||||||
MODEUS_URL=
|
|
||||||
# Имя пользователя для авторизации в Microsoft
|
|
||||||
MS_USERNAME=
|
|
||||||
# Пароль для авторизации в Microsoft
|
|
||||||
MS_PASSWORD=
|
|
||||||
# (опционально) Ключ API для защиты эндпоинта /auth/ms
|
|
||||||
API_KEY=
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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
4
.gitignore
vendored
@@ -483,7 +483,3 @@ $RECYCLE.BIN/
|
|||||||
|
|
||||||
# Windows shortcuts
|
# Windows shortcuts
|
||||||
*.lnk
|
*.lnk
|
||||||
|
|
||||||
|
|
||||||
src/appsettings.Development.json
|
|
||||||
|
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Sergey Karmanov
|
Copyright (c) 2025 serega404
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
83
README.md
83
README.md
@@ -1,83 +1,2 @@
|
|||||||
# Макросервис авторизации в Modeus через Microsoft
|
# ModeusSchedule.MSAuth
|
||||||
|
|
||||||
[](LICENSE)
|
|
||||||
[](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).
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
**/.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
|
|
||||||
@@ -1,162 +1,61 @@
|
|||||||
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(
|
public static async Task LoginMicrosoftAsync(IPage page, string username, string password, string loginUrl)
|
||||||
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 ex)
|
catch (PlaywrightException)
|
||||||
{
|
{
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -8,35 +8,16 @@ 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 в файле конфигурации или переменных окружения.");
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
var configuredApiKey = builder.Configuration["API_KEY"];
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<MicrosoftAuthService>();
|
builder.Services.AddSingleton<MicrosoftAuthService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
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) =>
|
app.MapGet("/auth/ms", async (MicrosoftAuthService mas, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
|
||||||
"profiles": {
|
|
||||||
"http": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": false,
|
|
||||||
"applicationUrl": "http://localhost:5000",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
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;
|
||||||
@@ -14,21 +13,16 @@ 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))
|
||||||
@@ -40,10 +34,7 @@ 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
|
||||||
{
|
{
|
||||||
@@ -54,35 +45,19 @@ public class MicrosoftAuthService(ILogger<MicrosoftAuthService> logger, IConfigu
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
logger.LogInformation("Старт авторизации через Microsoft");
|
logger.LogInformation("Старт авторизации через Microsoft");
|
||||||
await MicrosoftLoginHelper.LoginMicrosoftAsync(
|
await MicrosoftLoginHelper.LoginMicrosoftAsync(page, configuration["MS_USERNAME"]!, configuration["MS_PASSWORD"]!, configuration["MODEUS_URL"]!);
|
||||||
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();
|
||||||
@@ -139,30 +114,6 @@ 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
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Debug",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user