Compare commits
1
Commits
dev
..
3b9d197ffa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b9d197ffa |
No files matched your search
@@ -7,7 +7,7 @@ on:
|
|||||||
env:
|
env:
|
||||||
BACKEND_PATH: backend
|
BACKEND_PATH: backend
|
||||||
FRONTEND_PATH: frontend
|
FRONTEND_PATH: frontend
|
||||||
SERVER_DOMAIN: "git.zetcraft.ru"
|
SERVER_DOMAIN: ${{ gitea.server_url.replace('https://', '') }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
detect-changes:
|
detect-changes:
|
||||||
|
|||||||
@@ -189,12 +189,6 @@ Backend читает настройки из `backend/UniVerse.Api/appsettings.j
|
|||||||
- `ModeusApi:*` - base URL, API key и timeout.
|
- `ModeusApi:*` - base URL, API key и timeout.
|
||||||
- `Email:Smtp:*` - SMTP-настройки для уведомлений.
|
- `Email:Smtp:*` - SMTP-настройки для уведомлений.
|
||||||
|
|
||||||
Для аватара из Entra задайте `AzureAd__TokenCacheKey` — случайный ключ из 32 байт в Base64
|
|
||||||
(`openssl rand -base64 32`) — и `AzureAd__TokenCacheDirectory` — закрытый каталог для зашифрованного
|
|
||||||
кэша MSAL. В Docker Compose используйте `AZURE_AD_TOKEN_CACHE_KEY`: каталог подключён как постоянный том.
|
|
||||||
Храните ключ отдельно от тома и сохраняйте его при перезапуске контейнера; при смене ключа пользователям
|
|
||||||
понадобится повторно войти через Microsoft. Без ключа вход работает, но фото Entra недоступно.
|
|
||||||
|
|
||||||
Frontend использует переменные:
|
Frontend использует переменные:
|
||||||
|
|
||||||
- `VITE_API_BASE_URL` - базовый адрес API, по умолчанию `/api`.
|
- `VITE_API_BASE_URL` - базовый адрес API, по умолчанию `/api`.
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ public class AuthServiceTests
|
|||||||
var user = await db.Users.Include(u => u.TeacherProfile).SingleAsync();
|
var user = await db.Users.Include(u => u.TeacherProfile).SingleAsync();
|
||||||
Assert.Equal("sso-sub-1", user.MicrosoftId);
|
Assert.Equal("sso-sub-1", user.MicrosoftId);
|
||||||
Assert.Equal("person-1", user.TeacherProfile?.ModeusId);
|
Assert.Equal("person-1", user.TeacherProfile?.ModeusId);
|
||||||
await microsoftAuth.Received(1).SaveTokenCacheAsync(10, Arg.Any<MicrosoftTokenResult>(), Arg.Any<CancellationToken>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AppDbContext CreateDbContext()
|
private static AppDbContext CreateDbContext()
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using UniVerse.Infrastructure.ExternalServices;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace UniVerse.Api.Tests.Auth;
|
|
||||||
|
|
||||||
public class EntraTokenCacheStoreTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task SaveReadRemove_EncryptsTokensAndRestoresAfterNewStoreInstance()
|
|
||||||
{
|
|
||||||
var directory = Path.Combine(Path.GetTempPath(), $"universe-cache-test-{Guid.NewGuid():N}");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["AzureAd:TokenCacheDirectory"] = directory,
|
|
||||||
["AzureAd:TokenCacheKey"] = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
|
||||||
}).Build();
|
|
||||||
var store = new EntraTokenCacheStore(configuration);
|
|
||||||
var tokens = System.Text.Encoding.UTF8.GetBytes("sensitive-refresh-token");
|
|
||||||
|
|
||||||
await store.SaveAsync(7, "account-7", tokens, CancellationToken.None);
|
|
||||||
|
|
||||||
var file = File.ReadAllBytes(Path.Combine(directory, "user-7.bin"));
|
|
||||||
Assert.DoesNotContain("sensitive-refresh-token", System.Text.Encoding.UTF8.GetString(file));
|
|
||||||
var restored = await new EntraTokenCacheStore(configuration).ReadAsync(7, CancellationToken.None);
|
|
||||||
Assert.Equal("account-7", restored?.AccountId);
|
|
||||||
Assert.Equal(tokens, restored?.Cache);
|
|
||||||
|
|
||||||
await store.RemoveAsync(7);
|
|
||||||
Assert.Null(await store.ReadAsync(7, CancellationToken.None));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (Directory.Exists(directory)) Directory.Delete(directory, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using NSubstitute;
|
|
||||||
using UniVerse.Api.Controllers;
|
|
||||||
using UniVerse.Application.DTOs.Users;
|
|
||||||
using UniVerse.Application.Interfaces;
|
|
||||||
using UniVerse.Domain.Enums;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace UniVerse.Api.Tests.Auth;
|
|
||||||
|
|
||||||
public class ProfilePhotoControllerTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task Get_UsesCurrentUserAndReturnsPhotoWithoutCaching()
|
|
||||||
{
|
|
||||||
var auth = Substitute.For<IAuthService>();
|
|
||||||
auth.GetCurrentUserAsync(7).Returns(new CurrentUserDto(
|
|
||||||
7, "user@sfedu.ru", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
|
|
||||||
var microsoft = Substitute.For<IMicrosoftAuthClient>();
|
|
||||||
microsoft.GetPhotoAsync(7, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new MicrosoftPhotoResult([1, 2, 3], "image/jpeg"));
|
|
||||||
var controller = CreateController(auth, microsoft, 7);
|
|
||||||
|
|
||||||
var result = await controller.Get(CancellationToken.None);
|
|
||||||
|
|
||||||
var file = Assert.IsType<FileContentResult>(result);
|
|
||||||
Assert.Equal([1, 2, 3], file.FileContents);
|
|
||||||
Assert.Equal("image/jpeg", file.ContentType);
|
|
||||||
Assert.Equal("private, no-store", controller.Response.Headers.CacheControl);
|
|
||||||
await microsoft.Received(1).GetPhotoAsync(7, Arg.Any<CancellationToken>());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Get_ReturnsNotFoundWhenPhotoIsUnavailable()
|
|
||||||
{
|
|
||||||
var auth = Substitute.For<IAuthService>();
|
|
||||||
auth.GetCurrentUserAsync(7).Returns(new CurrentUserDto(
|
|
||||||
7, "user@sfedu.ru", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
|
|
||||||
var controller = CreateController(auth, Substitute.For<IMicrosoftAuthClient>(), 7);
|
|
||||||
|
|
||||||
Assert.IsType<NotFoundResult>(await controller.Get(CancellationToken.None));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ProfilePhotoController CreateController(IAuthService auth, IMicrosoftAuthClient microsoft, int userId)
|
|
||||||
{
|
|
||||||
var controller = new ProfilePhotoController(auth, microsoft);
|
|
||||||
controller.ControllerContext = new ControllerContext
|
|
||||||
{
|
|
||||||
HttpContext = new DefaultHttpContext
|
|
||||||
{
|
|
||||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
|
||||||
[new Claim(ClaimTypes.NameIdentifier, userId.ToString())], "test"))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return controller;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,7 +43,6 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
|
|||||||
|
|
||||||
// ── Users — current user ──────────────────────────────────────────────
|
// ── Users — current user ──────────────────────────────────────────────
|
||||||
yield return E("users/me GET [AnyAuth]", "GET", "api/v1/users/me", "Student");
|
yield return E("users/me GET [AnyAuth]", "GET", "api/v1/users/me", "Student");
|
||||||
yield return E("users/me/photo [AnyAuth]", "GET", "api/v1/users/me/photo", "Student");
|
|
||||||
yield return E("users/me PUT [AnyAuth]", "PUT", "api/v1/users/me", "Student",
|
yield return E("users/me PUT [AnyAuth]", "PUT", "api/v1/users/me", "Student",
|
||||||
body: """{"displayName":"Test","avatarUrl":null}""");
|
body: """{"displayName":"Test","avatarUrl":null}""");
|
||||||
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student");
|
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student");
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
||||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
@@ -228,8 +228,6 @@ public class AuthController : ControllerBase
|
|||||||
var refreshToken = Request.Cookies["refreshToken"];
|
var refreshToken = Request.Cookies["refreshToken"];
|
||||||
if (!string.IsNullOrEmpty(refreshToken))
|
if (!string.IsNullOrEmpty(refreshToken))
|
||||||
await _auth.RevokeRefreshTokenAsync(refreshToken);
|
await _auth.RevokeRefreshTokenAsync(refreshToken);
|
||||||
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
|
||||||
await _auth.RemoveMicrosoftTokenCacheAsync(userId);
|
|
||||||
Response.Cookies.Delete("refreshToken");
|
Response.Cookies.Delete("refreshToken");
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using UniVerse.Application.Interfaces;
|
|
||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Authorize]
|
|
||||||
[Route("api/v1/users/me/photo")]
|
|
||||||
public sealed class ProfilePhotoController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IAuthService _auth;
|
|
||||||
private readonly IMicrosoftAuthClient _microsoft;
|
|
||||||
|
|
||||||
public ProfilePhotoController(IAuthService auth, IMicrosoftAuthClient microsoft)
|
|
||||||
{
|
|
||||||
_auth = auth;
|
|
||||||
_microsoft = microsoft;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Получить фото текущего пользователя из Microsoft Entra.</summary>
|
|
||||||
[HttpGet]
|
|
||||||
[Produces("image/jpeg", "image/png", "image/gif", "image/webp")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> Get(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
|
||||||
await _auth.GetCurrentUserAsync(userId);
|
|
||||||
Response.Headers.CacheControl = "private, no-store";
|
|
||||||
var photo = await _microsoft.GetPhotoAsync(userId, cancellationToken);
|
|
||||||
if (photo is null) return NotFound();
|
|
||||||
return File(photo.Content, photo.ContentType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||||
ARG APP_UID=1000
|
ARG APP_UID=1000
|
||||||
RUN mkdir -p /var/lib/universe/entra-cache && chown -R $APP_UID:$APP_UID /var/lib/universe
|
|
||||||
USER $APP_UID
|
USER $APP_UID
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -144,11 +144,7 @@ builder.Services.AddScoped<IGamificationService, GamificationService>();
|
|||||||
builder.Services.AddScoped<IAchievementService, AchievementService>();
|
builder.Services.AddScoped<IAchievementService, AchievementService>();
|
||||||
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
|
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
|
||||||
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
|
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
|
||||||
builder.Services.AddSingleton<EntraTokenCacheStore>();
|
builder.Services.AddScoped<IMicrosoftAuthClient, MicrosoftAuthClient>();
|
||||||
builder.Services.AddHttpClient<IMicrosoftAuthClient, MicrosoftAuthClient>(client =>
|
|
||||||
{
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(10);
|
|
||||||
});
|
|
||||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||||
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
|
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
|
||||||
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
|
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.14.2" />
|
<PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" />
|
||||||
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="4.9.0" />
|
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="4.9.0" />
|
||||||
<PackageReference Include="Quartz.Dashboard" Version="3.18.1" />
|
<PackageReference Include="Quartz.Dashboard" Version="3.18.1" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||||
|
|||||||
@@ -2439,75 +2439,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/users/me/photo": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"ProfilePhoto"
|
|
||||||
],
|
|
||||||
"summary": "Получить фото текущего пользователя из Microsoft Entra.",
|
|
||||||
"description": "**Required:** any authenticated user",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized",
|
|
||||||
"content": {
|
|
||||||
"image/jpeg": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"image/png": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"image/gif": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"image/webp": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found",
|
|
||||||
"content": {
|
|
||||||
"image/jpeg": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"image/png": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"image/gif": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"image/webp": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"Bearer": [ ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/reviews": {
|
"/api/v1/reviews": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -6611,9 +6542,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Notifications"
|
"name": "Notifications"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "ProfilePhoto"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Reviews"
|
"name": "Reviews"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,5 @@ public interface IAuthService
|
|||||||
Task<AuthResult> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null);
|
Task<AuthResult> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null);
|
||||||
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
||||||
Task RevokeRefreshTokenAsync(string refreshToken);
|
Task RevokeRefreshTokenAsync(string refreshToken);
|
||||||
Task RemoveMicrosoftTokenCacheAsync(int userId);
|
|
||||||
Task<CurrentUserDto> GetCurrentUserAsync(int userId);
|
Task<CurrentUserDto> GetCurrentUserAsync(int userId);
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,6 @@ public interface IMicrosoftAuthClient
|
|||||||
string authorizationCode,
|
string authorizationCode,
|
||||||
string redirectUri,
|
string redirectUri,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task SaveTokenCacheAsync(int userId, MicrosoftTokenResult result, CancellationToken cancellationToken = default);
|
|
||||||
Task<MicrosoftPhotoResult?> GetPhotoAsync(int userId, CancellationToken cancellationToken = default);
|
|
||||||
Task RemoveTokenCacheAsync(int userId, CancellationToken cancellationToken = default);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record MicrosoftTokenResult(string IdToken, byte[]? TokenCache = null, string? AccountId = null);
|
public record MicrosoftTokenResult(string IdToken);
|
||||||
public record MicrosoftPhotoResult(byte[] Content, string ContentType);
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.22.0" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace UniVerse.Infrastructure.ExternalServices;
|
|
||||||
|
|
||||||
/// <summary>Зашифрованный кэш токенов MSAL. Ключ берётся только из конфигурации сервера.</summary>
|
|
||||||
public sealed class EntraTokenCacheStore
|
|
||||||
{
|
|
||||||
private readonly string _directory;
|
|
||||||
private readonly byte[]? _key;
|
|
||||||
private readonly ConcurrentDictionary<int, SemaphoreSlim> _locks = new();
|
|
||||||
|
|
||||||
public EntraTokenCacheStore(IConfiguration configuration)
|
|
||||||
{
|
|
||||||
_directory = configuration["AzureAd:TokenCacheDirectory"]
|
|
||||||
?? Path.Combine(Path.GetTempPath(), "universe-entra-token-cache");
|
|
||||||
var encodedKey = configuration["AzureAd:TokenCacheKey"];
|
|
||||||
if (!string.IsNullOrWhiteSpace(encodedKey))
|
|
||||||
{
|
|
||||||
try { _key = Convert.FromBase64String(encodedKey); }
|
|
||||||
catch (FormatException ex) { throw new InvalidOperationException("AzureAd:TokenCacheKey must be a base64-encoded 32-byte key.", ex); }
|
|
||||||
if (_key.Length != 32)
|
|
||||||
throw new InvalidOperationException("AzureAd:TokenCacheKey must be a base64-encoded 32-byte key.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsConfigured => _key is not null;
|
|
||||||
|
|
||||||
public async Task<T> LockedAsync<T>(int userId, Func<Task<T>> action)
|
|
||||||
{
|
|
||||||
var gate = _locks.GetOrAdd(userId, _ => new SemaphoreSlim(1, 1));
|
|
||||||
await gate.WaitAsync();
|
|
||||||
try { return await action(); }
|
|
||||||
finally { gate.Release(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SaveAsync(int userId, string accountId, byte[] cache, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (_key is null) return;
|
|
||||||
Directory.CreateDirectory(_directory);
|
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
File.SetUnixFileMode(_directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
|
||||||
|
|
||||||
var payload = JsonSerializer.SerializeToUtf8Bytes(new CachedTokens(accountId, cache));
|
|
||||||
var nonce = RandomNumberGenerator.GetBytes(12);
|
|
||||||
var ciphertext = new byte[payload.Length];
|
|
||||||
var tag = new byte[16];
|
|
||||||
using (var aes = new AesGcm(_key, tag.Length))
|
|
||||||
aes.Encrypt(nonce, payload, ciphertext, tag);
|
|
||||||
var encrypted = new byte[nonce.Length + tag.Length + ciphertext.Length];
|
|
||||||
nonce.CopyTo(encrypted, 0);
|
|
||||||
tag.CopyTo(encrypted, nonce.Length);
|
|
||||||
ciphertext.CopyTo(encrypted, nonce.Length + tag.Length);
|
|
||||||
|
|
||||||
var temporaryPath = Path.Combine(_directory, $".user-{userId}-{Guid.NewGuid():N}.tmp");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await File.WriteAllBytesAsync(temporaryPath, encrypted, cancellationToken);
|
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
File.SetUnixFileMode(temporaryPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
|
||||||
File.Move(temporaryPath, FilePath(userId), true);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (File.Exists(temporaryPath)) File.Delete(temporaryPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<CachedTokens?> ReadAsync(int userId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (_key is null || !File.Exists(FilePath(userId))) return null;
|
|
||||||
var encrypted = await File.ReadAllBytesAsync(FilePath(userId), cancellationToken);
|
|
||||||
if (encrypted.Length < 28) throw new CryptographicException("Invalid Entra token cache data.");
|
|
||||||
var plaintext = new byte[encrypted.Length - 28];
|
|
||||||
using (var aes = new AesGcm(_key, 16))
|
|
||||||
aes.Decrypt(encrypted.AsSpan(0, 12), encrypted.AsSpan(28), encrypted.AsSpan(12, 16), plaintext);
|
|
||||||
return JsonSerializer.Deserialize<CachedTokens>(plaintext);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task RemoveAsync(int userId)
|
|
||||||
{
|
|
||||||
var path = FilePath(userId);
|
|
||||||
if (File.Exists(path)) File.Delete(path);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FilePath(int userId) => Path.Combine(_directory, $"user-{userId}.bin");
|
|
||||||
|
|
||||||
public sealed record CachedTokens(string AccountId, byte[] Cache);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Identity.Client;
|
using Microsoft.Identity.Client;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Domain.Exceptions;
|
using UniVerse.Domain.Exceptions;
|
||||||
@@ -11,125 +8,16 @@ namespace UniVerse.Infrastructure.ExternalServices;
|
|||||||
public class MicrosoftAuthClient : IMicrosoftAuthClient
|
public class MicrosoftAuthClient : IMicrosoftAuthClient
|
||||||
{
|
{
|
||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
private readonly EntraTokenCacheStore _cache;
|
|
||||||
private readonly HttpClient _http;
|
|
||||||
private readonly ILogger<MicrosoftAuthClient> _logger;
|
|
||||||
|
|
||||||
public MicrosoftAuthClient(IConfiguration config, EntraTokenCacheStore cache, HttpClient http, ILogger<MicrosoftAuthClient> logger)
|
public MicrosoftAuthClient(IConfiguration config)
|
||||||
{
|
{
|
||||||
_config = config;
|
_config = config;
|
||||||
_cache = cache;
|
|
||||||
_http = http;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MicrosoftTokenResult> ExchangeAuthorizationCodeAsync(
|
public async Task<MicrosoftTokenResult> ExchangeAuthorizationCodeAsync(
|
||||||
string authorizationCode,
|
string authorizationCode,
|
||||||
string redirectUri,
|
string redirectUri,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
|
||||||
var app = CreateClient(redirectUri);
|
|
||||||
byte[]? serializedCache = null;
|
|
||||||
app.UserTokenCache.SetAfterAccess(args =>
|
|
||||||
{
|
|
||||||
if (args.HasStateChanged) serializedCache = args.TokenCache.SerializeMsalV3();
|
|
||||||
});
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await app.AcquireTokenByAuthorizationCode(["User.Read"], authorizationCode)
|
|
||||||
.ExecuteAsync(cancellationToken);
|
|
||||||
|
|
||||||
return new MicrosoftTokenResult(
|
|
||||||
result.IdToken,
|
|
||||||
serializedCache,
|
|
||||||
result.Account.HomeAccountId.Identifier);
|
|
||||||
}
|
|
||||||
catch (MsalException ex)
|
|
||||||
{
|
|
||||||
throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task SaveTokenCacheAsync(int userId, MicrosoftTokenResult result, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!_cache.IsConfigured || result.TokenCache is null || string.IsNullOrWhiteSpace(result.AccountId))
|
|
||||||
return Task.CompletedTask;
|
|
||||||
return _cache.LockedAsync(userId, async () =>
|
|
||||||
{
|
|
||||||
await _cache.SaveAsync(userId, result.AccountId, result.TokenCache, cancellationToken);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<MicrosoftPhotoResult?> GetPhotoAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!_cache.IsConfigured) return null;
|
|
||||||
string? accessToken;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
accessToken = await _cache.LockedAsync(userId, async () =>
|
|
||||||
{
|
|
||||||
var saved = await _cache.ReadAsync(userId, cancellationToken);
|
|
||||||
if (saved is null) return null;
|
|
||||||
|
|
||||||
var app = CreateClient();
|
|
||||||
byte[]? updatedCache = null;
|
|
||||||
app.UserTokenCache.SetBeforeAccess(args => args.TokenCache.DeserializeMsalV3(saved.Cache));
|
|
||||||
app.UserTokenCache.SetAfterAccess(args =>
|
|
||||||
{
|
|
||||||
if (args.HasStateChanged) updatedCache = args.TokenCache.SerializeMsalV3();
|
|
||||||
});
|
|
||||||
var account = await app.GetAccountAsync(saved.AccountId);
|
|
||||||
if (account is null) return null;
|
|
||||||
|
|
||||||
var result = await app.AcquireTokenSilent(["User.Read"], account)
|
|
||||||
.ExecuteAsync(cancellationToken);
|
|
||||||
if (updatedCache is not null)
|
|
||||||
await _cache.SaveAsync(userId, saved.AccountId, updatedCache, cancellationToken);
|
|
||||||
return result.AccessToken;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (MsalUiRequiredException)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Microsoft requires a new sign-in before the profile photo can be loaded for user {UserId}.", userId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is MsalException or IOException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException or JsonException)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Cannot read Microsoft token cache for user {UserId}.", userId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (accessToken is null) return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me/photo/$value");
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
||||||
using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
|
||||||
|
|
||||||
var contentType = response.Content.Headers.ContentType?.MediaType;
|
|
||||||
if (contentType is not ("image/jpeg" or "image/png" or "image/gif" or "image/webp"))
|
|
||||||
return null;
|
|
||||||
await response.Content.LoadIntoBufferAsync(5 * 1024 * 1024, cancellationToken);
|
|
||||||
return new MicrosoftPhotoResult(await response.Content.ReadAsByteArrayAsync(cancellationToken), contentType);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Cannot load Microsoft profile photo for user {UserId}.", userId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task RemoveTokenCacheAsync(int userId, CancellationToken cancellationToken = default) =>
|
|
||||||
_cache.LockedAsync(userId, async () =>
|
|
||||||
{
|
|
||||||
await _cache.RemoveAsync(userId);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
private IConfidentialClientApplication CreateClient(string? redirectUri = null)
|
|
||||||
{
|
{
|
||||||
var tenantId = _config["AzureAd:TenantId"];
|
var tenantId = _config["AzureAd:TenantId"];
|
||||||
var clientId = _config["AzureAd:ClientId"];
|
var clientId = _config["AzureAd:ClientId"];
|
||||||
@@ -141,10 +29,24 @@ public class MicrosoftAuthClient : IMicrosoftAuthClient
|
|||||||
|| string.IsNullOrWhiteSpace(clientSecret))
|
|| string.IsNullOrWhiteSpace(clientSecret))
|
||||||
throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret).");
|
throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret).");
|
||||||
|
|
||||||
var builder = ConfidentialClientApplicationBuilder.Create(clientId)
|
var authority = $"{instance.TrimEnd('/')}/{tenantId}";
|
||||||
|
|
||||||
|
var app = ConfidentialClientApplicationBuilder.Create(clientId)
|
||||||
.WithClientSecret(clientSecret)
|
.WithClientSecret(clientSecret)
|
||||||
.WithAuthority(new Uri($"{instance.TrimEnd('/')}/{tenantId}"));
|
.WithAuthority(new Uri(authority))
|
||||||
if (redirectUri is not null) builder = builder.WithRedirectUri(redirectUri);
|
.WithRedirectUri(redirectUri)
|
||||||
return builder.Build();
|
.Build();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await app.AcquireTokenByAuthorizationCode(["User.Read"], authorizationCode)
|
||||||
|
.ExecuteAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new MicrosoftTokenResult(result.IdToken);
|
||||||
|
}
|
||||||
|
catch (MsalException ex)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,18 +108,6 @@ public class AuthService : IAuthService
|
|||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(user.AvatarUrl))
|
|
||||||
await _microsoftAuth.SaveTokenCacheAsync(user.Id, result);
|
|
||||||
else
|
|
||||||
await _microsoftAuth.RemoveTokenCacheAsync(user.Id);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Could not save Microsoft token cache for user {UserId}.", user.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
var accessToken = GenerateAccessToken(user);
|
var accessToken = GenerateAccessToken(user);
|
||||||
var refreshToken = await GenerateRefreshTokenAsync(user.Id);
|
var refreshToken = await GenerateRefreshTokenAsync(user.Id);
|
||||||
await TrySendLoginNotificationAsync(user, ipAddress);
|
await TrySendLoginNotificationAsync(user, ipAddress);
|
||||||
@@ -227,18 +215,6 @@ public class AuthService : IAuthService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RemoveMicrosoftTokenCacheAsync(int userId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _microsoftAuth.RemoveTokenCacheAsync(userId);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Could not remove Microsoft token cache for user {UserId}.", userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<CurrentUserDto> GetCurrentUserAsync(int userId)
|
public async Task<CurrentUserDto> GetCurrentUserAsync(int userId)
|
||||||
{
|
{
|
||||||
var user = await _db.Users
|
var user = await _db.Users
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||||
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
|
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.9.0" />
|
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.5.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ services:
|
|||||||
# https://<domain>/api/v1/auth/callback/microsoft
|
# https://<domain>/api/v1/auth/callback/microsoft
|
||||||
- AzureAd:RedirectUri=${AzureAd_RedirectUri}
|
- AzureAd:RedirectUri=${AzureAd_RedirectUri}
|
||||||
- AzureAd:PostLoginRedirectUri=${AzureAd_PostLoginRedirectUri:-}
|
- AzureAd:PostLoginRedirectUri=${AzureAd_PostLoginRedirectUri:-}
|
||||||
- AzureAd:TokenCacheDirectory=/var/lib/universe/entra-cache
|
|
||||||
- AzureAd:TokenCacheKey=${AZURE_AD_TOKEN_CACHE_KEY}
|
|
||||||
|
|
||||||
- Jwt:Secret=${JWT_SECRET}
|
- Jwt:Secret=${JWT_SECRET}
|
||||||
- Jwt:Issuer=${JWT_ISSUER:-UniVerse}
|
- Jwt:Issuer=${JWT_ISSUER:-UniVerse}
|
||||||
@@ -48,8 +46,6 @@ services:
|
|||||||
- Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse}
|
- Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse}
|
||||||
|
|
||||||
- ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE:-universe};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
|
- ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE:-universe};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
|
||||||
volumes:
|
|
||||||
- entra_cache_data:/var/lib/universe/entra-cache
|
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend
|
||||||
- frontend
|
- frontend
|
||||||
@@ -85,7 +81,3 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
frontend:
|
frontend:
|
||||||
backend:
|
backend:
|
||||||
|
|
||||||
volumes:
|
|
||||||
database_data:
|
|
||||||
entra_cache_data:
|
|
||||||
@@ -19,8 +19,6 @@ services:
|
|||||||
- AzureAd:CallbackPath=${AzureAd_CallbackPath:-/signin-oidc}
|
- AzureAd:CallbackPath=${AzureAd_CallbackPath:-/signin-oidc}
|
||||||
- AzureAd:RedirectUri=${AzureAd_RedirectUri:-http://localhost:8088/api/v1/auth/callback/microsoft}
|
- AzureAd:RedirectUri=${AzureAd_RedirectUri:-http://localhost:8088/api/v1/auth/callback/microsoft}
|
||||||
- AzureAd:PostLoginRedirectUri=${AzureAd_PostLoginRedirectUri:-}
|
- AzureAd:PostLoginRedirectUri=${AzureAd_PostLoginRedirectUri:-}
|
||||||
- AzureAd:TokenCacheDirectory=/var/lib/universe/entra-cache
|
|
||||||
- AzureAd:TokenCacheKey=${AZURE_AD_TOKEN_CACHE_KEY}
|
|
||||||
|
|
||||||
- Jwt:Secret=${JWT_SECRET}
|
- Jwt:Secret=${JWT_SECRET}
|
||||||
- Jwt:Issuer=${JWT_ISSUER:-UniVerse}
|
- Jwt:Issuer=${JWT_ISSUER:-UniVerse}
|
||||||
@@ -45,8 +43,6 @@ services:
|
|||||||
- Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local}
|
- Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local}
|
||||||
- Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse}
|
- Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse}
|
||||||
- ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
|
- ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
|
||||||
volumes:
|
|
||||||
- entra_cache_data:/var/lib/universe/entra-cache
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
@@ -73,7 +69,3 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "80"
|
- "80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
database_data:
|
|
||||||
entra_cache_data:
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
test-results
|
|
||||||
+4
-5
@@ -4,16 +4,15 @@ ENV PNPM_HOME="/pnpm"
|
|||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS prod
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pnpm-lock.yaml ./
|
COPY pnpm-lock.yaml ./
|
||||||
RUN pnpm fetch
|
RUN pnpm fetch --prod
|
||||||
|
|
||||||
COPY . ./
|
COPY . ./
|
||||||
RUN pnpm install --offline --frozen-lockfile
|
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
FROM nginx:1.31-alpine
|
FROM nginx:1.30-alpine
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=prod /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.60.0",
|
"eslint-plugin-oxlint": "~1.72.0",
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
"eslint-plugin-vue-scoped-css": "^3.1.0",
|
"eslint-plugin-vue-scoped-css": "^3.1.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export const lecturesApi = {
|
|||||||
|
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
me: () => apiRequest<CurrentUserDto>('/users/me'),
|
me: () => apiRequest<CurrentUserDto>('/users/me'),
|
||||||
myPhoto: () => apiRequestBlob('/users/me/photo', { headers: { Accept: 'image/*' } }),
|
|
||||||
updateMe: (payload: { displayName?: string | null; avatarUrl?: string | null }) =>
|
updateMe: (payload: { displayName?: string | null; avatarUrl?: string | null }) =>
|
||||||
apiRequest<CurrentUserDto>('/users/me', {
|
apiRequest<CurrentUserDto>('/users/me', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
@@ -17,8 +17,6 @@ const isCoinDialogOpen = ref(false)
|
|||||||
const isLevelDialogOpen = ref(false)
|
const isLevelDialogOpen = ref(false)
|
||||||
const isSlotsDialogOpen = ref(false)
|
const isSlotsDialogOpen = ref(false)
|
||||||
const profileMenuRef = ref<HTMLElement | null>(null)
|
const profileMenuRef = ref<HTMLElement | null>(null)
|
||||||
const avatarFailed = ref(false)
|
|
||||||
watch(() => auth.avatarUrl, () => { avatarFailed.value = false })
|
|
||||||
|
|
||||||
const unreadCount = computed(() => userStore.unreadCount())
|
const unreadCount = computed(() => userStore.unreadCount())
|
||||||
const showStudentChips = computed(() => auth.user?.activeRole === 'student')
|
const showStudentChips = computed(() => auth.user?.activeRole === 'student')
|
||||||
@@ -194,14 +192,7 @@ onBeforeUnmount(() => {
|
|||||||
@click="toggleProfileMenu"
|
@click="toggleProfileMenu"
|
||||||
@keydown.esc.prevent="closeProfileMenu"
|
@keydown.esc.prevent="closeProfileMenu"
|
||||||
>
|
>
|
||||||
<img
|
<AppIcon class="avatar-icon" icon="user" :size="18" />
|
||||||
v-if="auth.avatarUrl && !avatarFailed"
|
|
||||||
class="avatar-photo"
|
|
||||||
:src="auth.avatarUrl"
|
|
||||||
alt=""
|
|
||||||
@error="avatarFailed = true"
|
|
||||||
/>
|
|
||||||
<AppIcon v-else class="avatar-icon" icon="user" :size="18" />
|
|
||||||
<span class="avatar-name" v-if="auth.user">{{ formatUserName(auth.user.name) }}</span>
|
<span class="avatar-name" v-if="auth.user">{{ formatUserName(auth.user.name) }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -454,12 +445,6 @@ onBeforeUnmount(() => {
|
|||||||
.avatar-icon {
|
.avatar-icon {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
.avatar-photo {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.avatar-name {
|
.avatar-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { authApi, usersApi } from '@/api'
|
import { authApi } from '@/api'
|
||||||
import { mapApiUser } from '@/api/mappers'
|
import { mapApiUser } from '@/api/mappers'
|
||||||
import { buildApiUrl, setApiAccessToken } from '@/api/client'
|
import { buildApiUrl, setApiAccessToken } from '@/api/client'
|
||||||
import type { AuthResponse } from '@/api/types'
|
import type { AuthResponse } from '@/api/types'
|
||||||
@@ -37,26 +37,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const initialized = ref(false)
|
const initialized = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const accessToken = ref<string | null>(localStorage.getItem(TOKEN_STORAGE_KEY))
|
const accessToken = ref<string | null>(localStorage.getItem(TOKEN_STORAGE_KEY))
|
||||||
const profilePhotoUrl = ref<string | null>(null)
|
|
||||||
let photoRequestId = 0
|
|
||||||
|
|
||||||
if (accessToken.value) setApiAccessToken(accessToken.value)
|
if (accessToken.value) setApiAccessToken(accessToken.value)
|
||||||
|
|
||||||
const isAuthenticated = computed(() => Boolean(user.value && accessToken.value))
|
const isAuthenticated = computed(() => Boolean(user.value && accessToken.value))
|
||||||
const avatarUrl = computed(() => user.value?.avatar || profilePhotoUrl.value)
|
|
||||||
|
|
||||||
async function loadProfilePhoto() {
|
|
||||||
const requestId = ++photoRequestId
|
|
||||||
if (!user.value || user.value.avatar) return
|
|
||||||
try {
|
|
||||||
const photo = await usersApi.myPhoto()
|
|
||||||
if (!photo.type.startsWith('image/') || photo.size === 0 || requestId !== photoRequestId) return
|
|
||||||
if (profilePhotoUrl.value) URL.revokeObjectURL(profilePhotoUrl.value)
|
|
||||||
profilePhotoUrl.value = URL.createObjectURL(photo)
|
|
||||||
} catch {
|
|
||||||
// The Microsoft photo is optional; retain the existing placeholder.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hydrateFromResponse(response: AuthResponse) {
|
async function hydrateFromResponse(response: AuthResponse) {
|
||||||
accessToken.value = response.accessToken
|
accessToken.value = response.accessToken
|
||||||
@@ -74,14 +58,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
await hydrateFromResponse(refreshed)
|
await hydrateFromResponse(refreshed)
|
||||||
const me = await authApi.me()
|
const me = await authApi.me()
|
||||||
user.value = restoreStoredActiveRole(mapApiUser(me))
|
user.value = restoreStoredActiveRole(mapApiUser(me))
|
||||||
void loadProfilePhoto()
|
|
||||||
return true
|
return true
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
if (accessToken.value) {
|
if (accessToken.value) {
|
||||||
try {
|
try {
|
||||||
const me = await authApi.me()
|
const me = await authApi.me()
|
||||||
user.value = restoreStoredActiveRole(mapApiUser(me))
|
user.value = restoreStoredActiveRole(mapApiUser(me))
|
||||||
void loadProfilePhoto()
|
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to local cleanup below.
|
// Fall through to local cleanup below.
|
||||||
@@ -108,9 +90,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const redirectUri = getAbsoluteAuthReturnUrl()
|
const redirectUri = getAbsoluteAuthReturnUrl()
|
||||||
const response = await authApi.loginMicrosoft(code, redirectUri)
|
const response = await authApi.loginMicrosoft(code, redirectUri)
|
||||||
await hydrateFromResponse(response)
|
await hydrateFromResponse(response)
|
||||||
const me = await authApi.me()
|
|
||||||
user.value = restoreStoredActiveRole(mapApiUser(me))
|
|
||||||
void loadProfilePhoto()
|
|
||||||
initialized.value = true
|
initialized.value = true
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -131,7 +110,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
setApiAccessToken(token)
|
setApiAccessToken(token)
|
||||||
const me = await authApi.me()
|
const me = await authApi.me()
|
||||||
user.value = restoreStoredActiveRole(mapApiUser(me))
|
user.value = restoreStoredActiveRole(mapApiUser(me))
|
||||||
void loadProfilePhoto()
|
|
||||||
initialized.value = true
|
initialized.value = true
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -158,9 +136,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
photoRequestId++
|
|
||||||
if (profilePhotoUrl.value) URL.revokeObjectURL(profilePhotoUrl.value)
|
|
||||||
profilePhotoUrl.value = null
|
|
||||||
user.value = null
|
user.value = null
|
||||||
accessToken.value = null
|
accessToken.value = null
|
||||||
localStorage.removeItem(TOKEN_STORAGE_KEY)
|
localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
@@ -183,7 +158,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
user,
|
user,
|
||||||
accessToken,
|
accessToken,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
avatarUrl,
|
|
||||||
loading,
|
loading,
|
||||||
initialized,
|
initialized,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
@@ -11,8 +11,6 @@ import AppIcon from '@/components/ui/AppIcon.vue'
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const user = computed(() => auth.user!)
|
const user = computed(() => auth.user!)
|
||||||
const avatarFailed = ref(false)
|
|
||||||
watch(() => auth.avatarUrl, () => { avatarFailed.value = false })
|
|
||||||
|
|
||||||
const userMetaLine = computed(() => {
|
const userMetaLine = computed(() => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
@@ -112,15 +110,7 @@ onMounted(() => {
|
|||||||
<div class="profile-grid">
|
<div class="profile-grid">
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="avatar">
|
<div class="avatar">👤</div>
|
||||||
<img
|
|
||||||
v-if="auth.avatarUrl && !avatarFailed"
|
|
||||||
:src="auth.avatarUrl"
|
|
||||||
:alt="`Фото ${user.name}`"
|
|
||||||
@error="avatarFailed = true"
|
|
||||||
/>
|
|
||||||
<span v-else aria-hidden="true">👤</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="name">{{ user.name }}</div>
|
<div class="name">{{ user.name }}</div>
|
||||||
<div class="email">{{ user.email }}</div>
|
<div class="email">{{ user.email }}</div>
|
||||||
@@ -255,20 +245,10 @@ onMounted(() => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 68px;
|
|
||||||
height: 68px;
|
|
||||||
flex: 0 0 68px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 38px;
|
font-size: 38px;
|
||||||
background: rgba(34, 197, 94, 0.15);
|
background: rgba(34, 197, 94, 0.15);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
padding: 12px;
|
||||||
.avatar img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
.name {
|
.name {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
|
||||||
import { mockApi } from './support/mockApi'
|
|
||||||
import { mockCurrentUser } from '../mocks/fixtures'
|
|
||||||
|
|
||||||
const photo = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><circle cx="10" cy="10" r="10" fill="green"/></svg>'
|
|
||||||
|
|
||||||
test('shows Microsoft photo in profile and topbar, including mobile layout', async ({ page }) => {
|
|
||||||
await mockApi(page, { authenticated: true })
|
|
||||||
await page.route('**/api/v1/users/me/photo', route => route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'image/svg+xml',
|
|
||||||
body: photo,
|
|
||||||
}))
|
|
||||||
|
|
||||||
await page.goto('/profile')
|
|
||||||
await expect(page.locator('.user-info img')).toBeVisible()
|
|
||||||
await expect(page.locator('.avatar-photo')).toBeVisible()
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 })
|
|
||||||
await expect(page.locator('.user-info img')).toBeVisible()
|
|
||||||
await expect(page.locator('.avatar-photo')).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('keeps custom avatar without requesting Microsoft photo', async ({ page }) => {
|
|
||||||
await mockApi(page, { authenticated: true })
|
|
||||||
let photoRequests = 0
|
|
||||||
await page.route('**/api/v1/auth/me', route => route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ ...mockCurrentUser, avatarUrl: `data:image/svg+xml,${encodeURIComponent(photo)}` }),
|
|
||||||
}))
|
|
||||||
await page.route('**/api/v1/users/me/photo', route => {
|
|
||||||
photoRequests++
|
|
||||||
return route.fulfill({ status: 404 })
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto('/profile')
|
|
||||||
await expect(page.locator('.user-info img')).toBeVisible()
|
|
||||||
expect(photoRequests).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('uses placeholder when Microsoft photo is unavailable', async ({ page }) => {
|
|
||||||
await mockApi(page, { authenticated: true })
|
|
||||||
await page.route('**/api/v1/users/me/photo', route => route.fulfill({ status: 404 }))
|
|
||||||
|
|
||||||
await page.goto('/profile')
|
|
||||||
await expect(page.locator('.user-info .avatar span')).toBeVisible()
|
|
||||||
await expect(page.locator('.avatar-icon')).toBeVisible()
|
|
||||||
})
|
|
||||||
Reference in new issue
Block a user