From 038f0d89a6df56de28e006c2b6fa02182680deb6 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 1 Oct 2026 00:17:16 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- .../Auth/AuthServiceTests.cs | 1 + .../Auth/EntraTokenCacheStoreTests.cs | 40 ++++++ .../Auth/ProfilePhotoControllerTests.cs | 59 ++++++++ .../EndpointAuthorizationTests.cs | 1 + .../Controllers/AuthController.cs | 2 + .../Controllers/ProfilePhotoController.cs | 37 +++++ backend/UniVerse.Api/Dockerfile | 1 + backend/UniVerse.Api/Program.cs | 6 +- backend/UniVerse.Api/openapi.json | 72 ++++++++++ .../Interfaces/IAuthService.cs | 1 + .../Interfaces/IMicrosoftAuthClient.cs | 7 +- .../ExternalServices/EntraTokenCacheStore.cs | 92 ++++++++++++ .../ExternalServices/MicrosoftAuthClient.cs | 136 +++++++++++++++--- .../Services/AuthService.cs | 24 ++++ docker-compose-prod.yml | 8 ++ docker-compose-test.yml | 8 ++ frontend/src/api/index.ts | 1 + frontend/src/components/layout/AppTopbar.vue | 19 ++- frontend/src/stores/auth.ts | 28 +++- frontend/src/views/student/ProfileView.vue | 26 +++- frontend/tests/e2e/avatar.spec.ts | 49 +++++++ 22 files changed, 598 insertions(+), 28 deletions(-) create mode 100644 backend/UniVerse.Api.Tests/Auth/EntraTokenCacheStoreTests.cs create mode 100644 backend/UniVerse.Api.Tests/Auth/ProfilePhotoControllerTests.cs create mode 100644 backend/UniVerse.Api/Controllers/ProfilePhotoController.cs create mode 100644 backend/UniVerse.Infrastructure/ExternalServices/EntraTokenCacheStore.cs create mode 100644 frontend/tests/e2e/avatar.spec.ts diff --git a/README.md b/README.md index 0d3f874..d5a7301 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,12 @@ Backend читает настройки из `backend/UniVerse.Api/appsettings.j - `ModeusApi:*` - base URL, API key и timeout. - `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 использует переменные: - `VITE_API_BASE_URL` - базовый адрес API, по умолчанию `/api`. @@ -292,4 +298,4 @@ Load testing helper: ```bash node frontend/scripts/loadtest-endpoints.js -``` \ No newline at end of file +``` diff --git a/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs b/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs index f81fa63..96bc5a8 100644 --- a/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs @@ -91,6 +91,7 @@ public class AuthServiceTests var user = await db.Users.Include(u => u.TeacherProfile).SingleAsync(); Assert.Equal("sso-sub-1", user.MicrosoftId); Assert.Equal("person-1", user.TeacherProfile?.ModeusId); + await microsoftAuth.Received(1).SaveTokenCacheAsync(10, Arg.Any(), Arg.Any()); } private static AppDbContext CreateDbContext() diff --git a/backend/UniVerse.Api.Tests/Auth/EntraTokenCacheStoreTests.cs b/backend/UniVerse.Api.Tests/Auth/EntraTokenCacheStoreTests.cs new file mode 100644 index 0000000..e6f6add --- /dev/null +++ b/backend/UniVerse.Api.Tests/Auth/EntraTokenCacheStoreTests.cs @@ -0,0 +1,40 @@ +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 + { + ["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); + } + } +} diff --git a/backend/UniVerse.Api.Tests/Auth/ProfilePhotoControllerTests.cs b/backend/UniVerse.Api.Tests/Auth/ProfilePhotoControllerTests.cs new file mode 100644 index 0000000..785e405 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Auth/ProfilePhotoControllerTests.cs @@ -0,0 +1,59 @@ +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(); + auth.GetCurrentUserAsync(7).Returns(new CurrentUserDto( + 7, "user@sfedu.ru", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow)); + var microsoft = Substitute.For(); + microsoft.GetPhotoAsync(7, Arg.Any()) + .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(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()); + } + + [Fact] + public async Task Get_ReturnsNotFoundWhenPhotoIsUnavailable() + { + var auth = Substitute.For(); + 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(), 7); + + Assert.IsType(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; + } +} diff --git a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs index 0c0ac32..c61e387 100644 --- a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs +++ b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs @@ -43,6 +43,7 @@ public class EndpointAuthorizationTests : IClassFixtureПолучить фото текущего пользователя из Microsoft Entra. + [HttpGet] + [Produces("image/jpeg", "image/png", "image/gif", "image/webp")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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); + } +} diff --git a/backend/UniVerse.Api/Dockerfile b/backend/UniVerse.Api/Dockerfile index 36cb69a..3e5b31a 100644 --- a/backend/UniVerse.Api/Dockerfile +++ b/backend/UniVerse.Api/Dockerfile @@ -1,5 +1,6 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base ARG APP_UID=1000 +RUN mkdir -p /var/lib/universe/entra-cache && chown -R $APP_UID:$APP_UID /var/lib/universe USER $APP_UID WORKDIR /app EXPOSE 8080 diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs index 9e98a79..99925a7 100644 --- a/backend/UniVerse.Api/Program.cs +++ b/backend/UniVerse.Api/Program.cs @@ -144,7 +144,11 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(client => +{ + client.Timeout = TimeSpan.FromSeconds(10); +}); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json index bd2ff7a..9505a99 100644 --- a/backend/UniVerse.Api/openapi.json +++ b/backend/UniVerse.Api/openapi.json @@ -2439,6 +2439,75 @@ ] } }, + "/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": { "post": { "tags": [ @@ -6542,6 +6611,9 @@ { "name": "Notifications" }, + { + "name": "ProfilePhoto" + }, { "name": "Reviews" }, diff --git a/backend/UniVerse.Application/Interfaces/IAuthService.cs b/backend/UniVerse.Application/Interfaces/IAuthService.cs index 4179803..0567bd4 100644 --- a/backend/UniVerse.Application/Interfaces/IAuthService.cs +++ b/backend/UniVerse.Application/Interfaces/IAuthService.cs @@ -9,5 +9,6 @@ public interface IAuthService Task DevLoginAsync(string email, string? displayName, IReadOnlyCollection roles, string? ipAddress = null); Task RefreshTokenAsync(string refreshToken); Task RevokeRefreshTokenAsync(string refreshToken); + Task RemoveMicrosoftTokenCacheAsync(int userId); Task GetCurrentUserAsync(int userId); } diff --git a/backend/UniVerse.Application/Interfaces/IMicrosoftAuthClient.cs b/backend/UniVerse.Application/Interfaces/IMicrosoftAuthClient.cs index e1389ca..d111454 100644 --- a/backend/UniVerse.Application/Interfaces/IMicrosoftAuthClient.cs +++ b/backend/UniVerse.Application/Interfaces/IMicrosoftAuthClient.cs @@ -6,6 +6,11 @@ public interface IMicrosoftAuthClient string authorizationCode, string redirectUri, CancellationToken cancellationToken = default); + + Task SaveTokenCacheAsync(int userId, MicrosoftTokenResult result, CancellationToken cancellationToken = default); + Task GetPhotoAsync(int userId, CancellationToken cancellationToken = default); + Task RemoveTokenCacheAsync(int userId, CancellationToken cancellationToken = default); } -public record MicrosoftTokenResult(string IdToken); +public record MicrosoftTokenResult(string IdToken, byte[]? TokenCache = null, string? AccountId = null); +public record MicrosoftPhotoResult(byte[] Content, string ContentType); diff --git a/backend/UniVerse.Infrastructure/ExternalServices/EntraTokenCacheStore.cs b/backend/UniVerse.Infrastructure/ExternalServices/EntraTokenCacheStore.cs new file mode 100644 index 0000000..45181e8 --- /dev/null +++ b/backend/UniVerse.Infrastructure/ExternalServices/EntraTokenCacheStore.cs @@ -0,0 +1,92 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Configuration; + +namespace UniVerse.Infrastructure.ExternalServices; + +/// Зашифрованный кэш токенов MSAL. Ключ берётся только из конфигурации сервера. +public sealed class EntraTokenCacheStore +{ + private readonly string _directory; + private readonly byte[]? _key; + private readonly ConcurrentDictionary _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 LockedAsync(int userId, Func> 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 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(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); +} diff --git a/backend/UniVerse.Infrastructure/ExternalServices/MicrosoftAuthClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/MicrosoftAuthClient.cs index 53ceff5..09f0445 100644 --- a/backend/UniVerse.Infrastructure/ExternalServices/MicrosoftAuthClient.cs +++ b/backend/UniVerse.Infrastructure/ExternalServices/MicrosoftAuthClient.cs @@ -1,4 +1,7 @@ +using System.Net.Http.Headers; +using System.Text.Json; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; using UniVerse.Application.Interfaces; using UniVerse.Domain.Exceptions; @@ -8,16 +11,125 @@ namespace UniVerse.Infrastructure.ExternalServices; public class MicrosoftAuthClient : IMicrosoftAuthClient { private readonly IConfiguration _config; + private readonly EntraTokenCacheStore _cache; + private readonly HttpClient _http; + private readonly ILogger _logger; - public MicrosoftAuthClient(IConfiguration config) + public MicrosoftAuthClient(IConfiguration config, EntraTokenCacheStore cache, HttpClient http, ILogger logger) { _config = config; + _cache = cache; + _http = http; + _logger = logger; } public async Task ExchangeAuthorizationCodeAsync( string authorizationCode, string redirectUri, 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 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 clientId = _config["AzureAd:ClientId"]; @@ -29,24 +141,10 @@ public class MicrosoftAuthClient : IMicrosoftAuthClient || string.IsNullOrWhiteSpace(clientSecret)) throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret)."); - var authority = $"{instance.TrimEnd('/')}/{tenantId}"; - - var app = ConfidentialClientApplicationBuilder.Create(clientId) + var builder = ConfidentialClientApplicationBuilder.Create(clientId) .WithClientSecret(clientSecret) - .WithAuthority(new Uri(authority)) - .WithRedirectUri(redirectUri) - .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}"); - } + .WithAuthority(new Uri($"{instance.TrimEnd('/')}/{tenantId}")); + if (redirectUri is not null) builder = builder.WithRedirectUri(redirectUri); + return builder.Build(); } } diff --git a/backend/UniVerse.Infrastructure/Services/AuthService.cs b/backend/UniVerse.Infrastructure/Services/AuthService.cs index 679e58f..b0dbf58 100644 --- a/backend/UniVerse.Infrastructure/Services/AuthService.cs +++ b/backend/UniVerse.Infrastructure/Services/AuthService.cs @@ -108,6 +108,18 @@ public class AuthService : IAuthService 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 refreshToken = await GenerateRefreshTokenAsync(user.Id); await TrySendLoginNotificationAsync(user, ipAddress); @@ -215,6 +227,18 @@ 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 GetCurrentUserAsync(int userId) { var user = await _db.Users diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 9c943d7..8ad10f3 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -17,6 +17,8 @@ services: # https:///api/v1/auth/callback/microsoft - AzureAd:RedirectUri=${AzureAd_RedirectUri} - AzureAd:PostLoginRedirectUri=${AzureAd_PostLoginRedirectUri:-} + - AzureAd:TokenCacheDirectory=/var/lib/universe/entra-cache + - AzureAd:TokenCacheKey=${AZURE_AD_TOKEN_CACHE_KEY} - Jwt:Secret=${JWT_SECRET} - Jwt:Issuer=${JWT_ISSUER:-UniVerse} @@ -46,6 +48,8 @@ services: - Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse} - 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: - backend - frontend @@ -81,3 +85,7 @@ services: networks: frontend: backend: + +volumes: + database_data: + entra_cache_data: diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 567d6a4..7e585bb 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -19,6 +19,8 @@ services: - AzureAd:CallbackPath=${AzureAd_CallbackPath:-/signin-oidc} - AzureAd:RedirectUri=${AzureAd_RedirectUri:-http://localhost:8088/api/v1/auth/callback/microsoft} - AzureAd:PostLoginRedirectUri=${AzureAd_PostLoginRedirectUri:-} + - AzureAd:TokenCacheDirectory=/var/lib/universe/entra-cache + - AzureAd:TokenCacheKey=${AZURE_AD_TOKEN_CACHE_KEY} - Jwt:Secret=${JWT_SECRET} - Jwt:Issuer=${JWT_ISSUER:-UniVerse} @@ -43,6 +45,8 @@ services: - Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local} - Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse} - 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: image: postgres:18-alpine @@ -69,3 +73,7 @@ services: ports: - "80" restart: unless-stopped + +volumes: + database_data: + entra_cache_data: diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b61f772..37aa60b 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -64,6 +64,7 @@ export const lecturesApi = { export const usersApi = { me: () => apiRequest('/users/me'), + myPhoto: () => apiRequestBlob('/users/me/photo', { headers: { Accept: 'image/*' } }), updateMe: (payload: { displayName?: string | null; avatarUrl?: string | null }) => apiRequest('/users/me', { method: 'PUT', diff --git a/frontend/src/components/layout/AppTopbar.vue b/frontend/src/components/layout/AppTopbar.vue index af1709f..9224c6a 100644 --- a/frontend/src/components/layout/AppTopbar.vue +++ b/frontend/src/components/layout/AppTopbar.vue @@ -1,5 +1,5 @@