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
🚀 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:
@@ -0,0 +1,12 @@
|
||||
namespace UniVerse.Infrastructure.Notifications;
|
||||
|
||||
public class EmailNotificationOptions
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public int Port { get; set; } = 587;
|
||||
public bool EnableSsl { get; set; } = true;
|
||||
public string? UserName { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string FromAddress { get; set; } = string.Empty;
|
||||
public string? FromName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Infrastructure.Notifications;
|
||||
|
||||
public class EmailNotificationProvider : INotificationProvider
|
||||
{
|
||||
private readonly EmailNotificationOptions _options;
|
||||
private readonly ILogger<EmailNotificationProvider> _logger;
|
||||
|
||||
public EmailNotificationProvider(IOptions<EmailNotificationOptions> options, ILogger<EmailNotificationProvider> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Channel => NotificationChannels.Email;
|
||||
|
||||
public async Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateOptions();
|
||||
|
||||
using var mailMessage = new MailMessage
|
||||
{
|
||||
From = new MailAddress(_options.FromAddress, _options.FromName),
|
||||
Subject = message.Subject,
|
||||
Body = message.Body,
|
||||
IsBodyHtml = false
|
||||
};
|
||||
mailMessage.To.Add(new MailAddress(message.Recipient, message.RecipientName));
|
||||
|
||||
using var client = new SmtpClient(_options.Host, _options.Port)
|
||||
{
|
||||
EnableSsl = _options.EnableSsl
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.UserName))
|
||||
{
|
||||
client.Credentials = new NetworkCredential(_options.UserName, _options.Password);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Sending email notification to {Recipient}", message.Recipient);
|
||||
await client.SendMailAsync(mailMessage, cancellationToken);
|
||||
}
|
||||
|
||||
private void ValidateOptions()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.Host))
|
||||
throw new InvalidOperationException("Email:Smtp:Host is not configured.");
|
||||
if (string.IsNullOrWhiteSpace(_options.FromAddress))
|
||||
throw new InvalidOperationException("Email:Smtp:FromAddress is not configured.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Quartz;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Infrastructure.Notifications;
|
||||
|
||||
[DisallowConcurrentExecution]
|
||||
public class NotificationJob : IJob
|
||||
{
|
||||
public const string MessageDataKey = "message";
|
||||
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly ILogger<NotificationJob> _logger;
|
||||
|
||||
public NotificationJob(INotificationService notifications, ILogger<NotificationJob> logger)
|
||||
{
|
||||
_notifications = notifications;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
var payload = context.MergedJobDataMap.GetString(MessageDataKey);
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
_logger.LogWarning("Notification job {JobKey} does not contain message payload", context.JobDetail.Key);
|
||||
return;
|
||||
}
|
||||
|
||||
var message = JsonSerializer.Deserialize<NotificationMessage>(payload)
|
||||
?? throw new InvalidOperationException("Scheduled notification payload cannot be deserialized.");
|
||||
|
||||
await _notifications.SendAsync(message, context.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Infrastructure.Notifications;
|
||||
|
||||
public class NotificationService : INotificationService
|
||||
{
|
||||
private readonly IEnumerable<INotificationProvider> _providers;
|
||||
private readonly INotificationScheduler _scheduler;
|
||||
private readonly ILogger<NotificationService> _logger;
|
||||
|
||||
public NotificationService(
|
||||
IEnumerable<INotificationProvider> providers,
|
||||
INotificationScheduler scheduler,
|
||||
ILogger<NotificationService> logger)
|
||||
{
|
||||
_providers = providers;
|
||||
_scheduler = scheduler;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(message.Channel);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(message.Recipient);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(message.Subject);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(message.Body);
|
||||
|
||||
var provider = _providers.FirstOrDefault(p => string.Equals(p.Channel, message.Channel, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException($"Notification provider for channel '{message.Channel}' is not registered.");
|
||||
|
||||
_logger.LogInformation("Dispatching notification through {Channel} to {Recipient}", message.Channel, message.Recipient);
|
||||
await provider.SendAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<ScheduledNotificationResponse> ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var message = new NotificationMessage(
|
||||
request.Channel,
|
||||
request.Recipient,
|
||||
request.Subject,
|
||||
request.Body,
|
||||
request.RecipientName,
|
||||
request.Metadata);
|
||||
|
||||
return _scheduler.ScheduleAsync(message, request.SendAt, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Quartz;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Infrastructure.Notifications;
|
||||
|
||||
public class QuartzNotificationScheduler : INotificationScheduler
|
||||
{
|
||||
private const string NotificationGroup = "notifications";
|
||||
|
||||
private readonly ISchedulerFactory _schedulerFactory;
|
||||
private readonly ILogger<QuartzNotificationScheduler> _logger;
|
||||
|
||||
public QuartzNotificationScheduler(ISchedulerFactory schedulerFactory, ILogger<QuartzNotificationScheduler> logger)
|
||||
{
|
||||
_schedulerFactory = schedulerFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ScheduledNotificationResponse> ScheduleAsync(NotificationMessage message, DateTimeOffset sendAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (sendAt <= DateTimeOffset.UtcNow)
|
||||
throw new ArgumentException("Scheduled notification time must be in the future.", nameof(sendAt));
|
||||
|
||||
var scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
|
||||
var jobId = Guid.NewGuid().ToString("N");
|
||||
var jobKey = new JobKey(jobId, NotificationGroup);
|
||||
var payload = JsonSerializer.Serialize(message);
|
||||
|
||||
var job = JobBuilder.Create<NotificationJob>()
|
||||
.WithIdentity(jobKey)
|
||||
.UsingJobData(NotificationJob.MessageDataKey, payload)
|
||||
.Build();
|
||||
|
||||
var trigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{jobId}.trigger", NotificationGroup)
|
||||
.ForJob(job)
|
||||
.StartAt(sendAt)
|
||||
.Build();
|
||||
|
||||
await scheduler.ScheduleJob(job, trigger, cancellationToken);
|
||||
_logger.LogInformation("Scheduled notification job {JobId} for {SendAt}", jobId, sendAt);
|
||||
|
||||
return new ScheduledNotificationResponse(jobId, sendAt);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
<PackageReference Include="Quartz" Version="3.18.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user