feat: добавил отправку уведомлений по почте
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 10s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m17s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 12s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s

This commit is contained in:
2026-05-11 05:09:00 +03:00
parent 44234cc42d
commit a0a0575a99
22 changed files with 464 additions and 16 deletions
@@ -5,8 +5,10 @@ using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using UniVerse.Application.DTOs.Auth;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces;
using UniVerse.Application.Mappings;
@@ -22,15 +24,24 @@ public class AuthService : IAuthService
private readonly AppDbContext _db;
private readonly IConfiguration _config;
private readonly IGamificationService _gamification;
private readonly INotificationService _notifications;
private readonly ILogger<AuthService> _logger;
public AuthService(AppDbContext db, IConfiguration config, IGamificationService gamification)
public AuthService(
AppDbContext db,
IConfiguration config,
IGamificationService gamification,
INotificationService notifications,
ILogger<AuthService> logger)
{
_db = db;
_config = config;
_gamification = gamification;
_notifications = notifications;
_logger = logger;
}
public async Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null)
public async Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null)
{
var tenantId = _config["AzureAd:TenantId"];
var clientId = _config["AzureAd:ClientId"];
@@ -38,7 +49,7 @@ public class AuthService : IAuthService
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
throw new UnauthorizedException("Microsoft authentication is not configured (AzureAd:TenantId/ClientId/ClientSecret).");
throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret).");
var effectiveRedirectUri = redirectUri
?? _config["AzureAd:RedirectUri"]
@@ -60,7 +71,7 @@ public class AuthService : IAuthService
}
catch (MsalException ex)
{
throw new UnauthorizedException($"Microsoft authentication failed: {ex.Message}");
throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}");
}
// Parse claims directly from the ID token provided by Microsoft
@@ -71,7 +82,7 @@ public class AuthService : IAuthService
var name = idToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
if (string.IsNullOrEmpty(email))
throw new UnauthorizedException("Email not found in Microsoft token claims.");
throw new UnauthorizedException("Email не найден в токене Microsoft.");
// Automatically provision user
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
@@ -93,11 +104,12 @@ public class AuthService : IAuthService
}
else if (!user.IsActive)
{
throw new ForbiddenException("Account is deactivated.");
throw new ForbiddenException("Аккаунт деактивирован.");
}
var accessToken = GenerateAccessToken(user);
var refreshToken = await GenerateRefreshTokenAsync(user.Id);
await TrySendLoginNotificationAsync(user, ipAddress);
return new AuthResult(
new AuthResponse(
@@ -109,7 +121,7 @@ public class AuthService : IAuthService
);
}
public async Task<AuthResult> DevLoginAsync(string email, string? displayName, UserRole role)
public async Task<AuthResult> DevLoginAsync(string email, string? displayName, UserRole role, string? ipAddress = null)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
@@ -139,10 +151,11 @@ public class AuthService : IAuthService
}
if (!user.IsActive)
throw new ForbiddenException("Account is deactivated.");
throw new ForbiddenException("Аккаунт деактивирован.");
var accessToken = GenerateAccessToken(user);
var refreshToken = await GenerateRefreshTokenAsync(user.Id);
await TrySendLoginNotificationAsync(user, ipAddress);
return new AuthResult(
new AuthResponse(
@@ -161,7 +174,7 @@ public class AuthService : IAuthService
.FirstOrDefaultAsync(rt => rt.Token == refreshToken);
if (token == null || !token.IsActive)
throw new ForbiddenException("Invalid or expired refresh token.");
throw new ForbiddenException("Неверный или просроченный токен обновления.");
// Revoke old token
token.RevokedAt = DateTime.UtcNow;
@@ -199,6 +212,33 @@ public class AuthService : IAuthService
return user.ToDto(_gamification.CalculateLevel(user.Xp));
}
private async Task TrySendLoginNotificationAsync(User user, string? ipAddress)
{
try
{
var loginIpAddress = string.IsNullOrWhiteSpace(ipAddress) ? "unknown" : ipAddress;
var loginTime = DateTimeOffset.UtcNow;
var message = new NotificationMessage(
NotificationChannels.Email,
user.Email,
"Вход в аккаунт UniVerse",
$"Здравствуйте, {user.DisplayName ?? user.Email}!\n\nБыл выполнен вход в ваш аккаунт UniVerse в {loginTime:O} с IP-адреса: {loginIpAddress}.\n\nЕсли это были не вы, пожалуйста, немедленно свяжитесь с поддержкой.",
user.DisplayName,
new Dictionary<string, string>
{
["event"] = "account_login",
["ip_address"] = loginIpAddress,
["login_time_utc"] = loginTime.ToString("O")
});
await _notifications.SendAsync(message);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send login notification to user {UserId}", user.Id);
}
}
private string GenerateAccessToken(User user)
{
var key = new SymmetricSecurityKey(