feat: добавил отображение аватарки
This commit is contained in:
1 parent
34e450815b
commit
038f0d89a6
22 files changed
+598
-28
No files matched your search
@@ -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<MicrosoftTokenResult>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static AppDbContext CreateDbContext()
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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,6 +43,7 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
|
||||
|
||||
// ── Users — current user ──────────────────────────────────────────────
|
||||
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",
|
||||
body: """{"displayName":"Test","avatarUrl":null}""");
|
||||
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student");
|
||||
|
||||
@@ -228,6 +228,8 @@ public class AuthController : ControllerBase
|
||||
var refreshToken = Request.Cookies["refreshToken"];
|
||||
if (!string.IsNullOrEmpty(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");
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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,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
|
||||
|
||||
@@ -144,7 +144,11 @@ builder.Services.AddScoped<IGamificationService, GamificationService>();
|
||||
builder.Services.AddScoped<IAchievementService, AchievementService>();
|
||||
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
|
||||
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
|
||||
builder.Services.AddScoped<IMicrosoftAuthClient, MicrosoftAuthClient>();
|
||||
builder.Services.AddSingleton<EntraTokenCacheStore>();
|
||||
builder.Services.AddHttpClient<IMicrosoftAuthClient, MicrosoftAuthClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
|
||||
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -9,5 +9,6 @@ public interface IAuthService
|
||||
Task<AuthResult> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null);
|
||||
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
||||
Task RevokeRefreshTokenAsync(string refreshToken);
|
||||
Task RemoveMicrosoftTokenCacheAsync(int userId);
|
||||
Task<CurrentUserDto> GetCurrentUserAsync(int userId);
|
||||
}
|
||||
@@ -6,6 +6,11 @@ public interface IMicrosoftAuthClient
|
||||
string authorizationCode,
|
||||
string redirectUri,
|
||||
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);
|
||||
public record MicrosoftTokenResult(string IdToken, byte[]? TokenCache = null, string? AccountId = null);
|
||||
public record MicrosoftPhotoResult(byte[] Content, string ContentType);
|
||||
@@ -0,0 +1,92 @@
|
||||
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,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<MicrosoftAuthClient> _logger;
|
||||
|
||||
public MicrosoftAuthClient(IConfiguration config)
|
||||
public MicrosoftAuthClient(IConfiguration config, EntraTokenCacheStore cache, HttpClient http, ILogger<MicrosoftAuthClient> logger)
|
||||
{
|
||||
_config = config;
|
||||
_cache = cache;
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<MicrosoftTokenResult> 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<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 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();
|
||||
}
|
||||
}
|
||||
@@ -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<CurrentUserDto> GetCurrentUserAsync(int userId)
|
||||
{
|
||||
var user = await _db.Users
|
||||
|
||||
Reference in new issue
Block a user