diff --git a/.env.example b/.env.example index 6c4354b..5a8a3c3 100644 --- a/.env.example +++ b/.env.example @@ -30,5 +30,14 @@ LLM_MODEL= MODEUS_API_BASE_URL= MODEUS_API_KEY= +# Email SMTP +EMAIL_SMTP_HOST= +EMAIL_SMTP_PORT=587 +EMAIL_SMTP_ENABLE_SSL=true +EMAIL_SMTP_USERNAME= +EMAIL_SMTP_PASSWORD= +EMAIL_SMTP_FROM_ADDRESS=no-reply@universe.local +EMAIL_SMTP_FROM_NAME=UniVerse + # Gamification GAMIFICATION_XP_THRESHOLDS=[0, 100, 300, 600, 1000, 1500, 2500, 4000] \ No newline at end of file diff --git a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs index dfedea6..bd98025 100644 --- a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs +++ b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs @@ -152,6 +152,12 @@ public class EndpointAuthorizationTests : IClassFixture diff --git a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs index e06d5f2..410c41a 100644 --- a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs +++ b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs @@ -13,6 +13,7 @@ using UniVerse.Application.DTOs.Courses; using UniVerse.Application.DTOs.Gamification; using UniVerse.Application.DTOs.Lectures; using UniVerse.Application.DTOs.Locations; +using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.DTOs.Reviews; using UniVerse.Application.DTOs.Sync; using UniVerse.Application.DTOs.Tags; @@ -96,6 +97,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory ReplaceWithSubstitute(services, CreateSyncServiceStub()); ReplaceWithSubstitute(services, Substitute.For()); ReplaceWithSubstitute(services, Substitute.For()); + ReplaceWithSubstitute(services, CreateNotificationServiceStub()); }); } @@ -115,9 +117,9 @@ public class ApiWebApplicationFactory : WebApplicationFactory new AuthResponse("access_token", DateTime.UtcNow.AddHours(1), new UserAuthDto(1, "test@test.com", "Test User", UserRole.Student)), "refresh_token"); - stub.LoginWithMicrosoftAsync(Arg.Any(), Arg.Any()) + stub.LoginWithMicrosoftAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(authResult); - stub.DevLoginAsync(Arg.Any(), Arg.Any(), Arg.Any()) + stub.DevLoginAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(authResult); stub.RefreshTokenAsync(Arg.Any()).Returns(authResult); stub.GetCurrentUserAsync(Arg.Any()) @@ -125,6 +127,16 @@ public class ApiWebApplicationFactory : WebApplicationFactory return stub; } + private static INotificationService CreateNotificationServiceStub() + { + var stub = Substitute.For(); + stub.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + stub.ScheduleAsync(Arg.Any(), Arg.Any()) + .Returns(new ScheduledNotificationResponse("test-job", DateTimeOffset.UtcNow.AddMinutes(5))); + return stub; + } + private static IUserService CreateUserServiceStub() { var stub = Substitute.For(); diff --git a/backend/UniVerse.Api/Controllers/AuthController.cs b/backend/UniVerse.Api/Controllers/AuthController.cs index b850fb4..0bea199 100644 --- a/backend/UniVerse.Api/Controllers/AuthController.cs +++ b/backend/UniVerse.Api/Controllers/AuthController.cs @@ -40,7 +40,7 @@ public class AuthController : ControllerBase [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> LoginMicrosoft([FromBody] LoginMicrosoftRequest request) { - var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri); + var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri, GetClientIpAddress()); SetRefreshTokenCookie(result.RefreshToken); return Ok(result.Response); } @@ -151,7 +151,7 @@ public class AuthController : ControllerBase var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft"); - var result = await _auth.LoginWithMicrosoftAsync(code, redirectUri); + var result = await _auth.LoginWithMicrosoftAsync(code, redirectUri, GetClientIpAddress()); SetRefreshTokenCookie(result.RefreshToken); var returnUrl = Request.Cookies[MicrosoftReturnUrlCookieName] ?? _config["AzureAd:PostLoginRedirectUri"]; @@ -184,7 +184,7 @@ public class AuthController : ControllerBase { if (!HttpContext.RequestServices.GetRequiredService().IsDevelopment()) return NotFound(); - var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, request.Role); + var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, request.Role, GetClientIpAddress()); SetRefreshTokenCookie(result.RefreshToken); return Ok(result.Response); } @@ -245,6 +245,21 @@ public class AuthController : ControllerBase return Ok(user); } + private string? GetClientIpAddress() + { + if (Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor)) + { + var firstForwardedAddress = forwardedFor.ToString().Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(firstForwardedAddress)) + return firstForwardedAddress; + } + + if (Request.Headers.TryGetValue("X-Real-IP", out var realIp) && !string.IsNullOrWhiteSpace(realIp)) + return realIp; + + return HttpContext.Connection.RemoteIpAddress?.ToString(); + } + private void SetRefreshTokenCookie(string token) { Response.Cookies.Append("refreshToken", token, new CookieOptions diff --git a/backend/UniVerse.Api/Controllers/NotificationsController.cs b/backend/UniVerse.Api/Controllers/NotificationsController.cs new file mode 100644 index 0000000..5cfc3ec --- /dev/null +++ b/backend/UniVerse.Api/Controllers/NotificationsController.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UniVerse.Application.DTOs.Notifications; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Api.Controllers; + +/// Отправка и планирование уведомлений через доступные каналы. +[ApiController] +[Route("api/v1/notifications")] +[Authorize(Roles = "Admin")] +[Produces("application/json")] +public class NotificationsController : ControllerBase +{ + private readonly INotificationService _notifications; + + public NotificationsController(INotificationService notifications) + { + _notifications = notifications; + } + + /// Отправить уведомление немедленно. + /// + /// Канал задаётся строкой, например `email`. Новые провайдеры добавляются через `INotificationProvider`. + /// + /// Канал, получатель, тема и текст уведомления. + /// Уведомление принято к отправке. + /// Требуется аутентификация. + /// Требуется роль Admin. + [HttpPost("send")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task Send([FromBody] SendNotificationRequest request, CancellationToken cancellationToken) + { + var message = new NotificationMessage( + request.Channel, + request.Recipient, + request.Subject, + request.Body, + request.RecipientName, + request.Metadata); + + await _notifications.SendAsync(message, cancellationToken); + return Accepted(); + } + + /// Запланировать отложенную отправку уведомления через Quartz.NET. + /// Уведомление и момент отправки. + /// Уведомление поставлено в очередь Quartz.NET. + /// Требуется аутентификация. + /// Требуется роль Admin. + [HttpPost("schedule")] + [ProducesResponseType(typeof(ScheduledNotificationResponse), StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> Schedule([FromBody] ScheduleNotificationRequest request, CancellationToken cancellationToken) + { + var response = await _notifications.ScheduleAsync(request, cancellationToken); + return Accepted(response); + } +} diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs index d4e88aa..099f9f0 100644 --- a/backend/UniVerse.Api/Program.cs +++ b/backend/UniVerse.Api/Program.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; +using Quartz; using Serilog; using UniVerse.Api.BackgroundServices; using UniVerse.Api.Filters; @@ -12,6 +13,7 @@ using UniVerse.Application.Interfaces; using UniVerse.Infrastructure.Services; using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.ExternalServices; +using UniVerse.Infrastructure.Notifications; var builder = WebApplication.CreateBuilder(args); @@ -89,6 +91,17 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddTransient(); +builder.Services.Configure(builder.Configuration.GetSection("Email:Smtp")); + +builder.Services.AddQuartz(); +builder.Services.AddQuartzHostedService(options => +{ + options.WaitForJobsToComplete = true; +}); // --- HTTP Clients --- builder.Services.AddHttpClient(client => diff --git a/backend/UniVerse.Api/UniVerse.Api.csproj b/backend/UniVerse.Api/UniVerse.Api.csproj index 29343ee..021e9aa 100644 --- a/backend/UniVerse.Api/UniVerse.Api.csproj +++ b/backend/UniVerse.Api/UniVerse.Api.csproj @@ -24,6 +24,7 @@ + diff --git a/backend/UniVerse.Api/appsettings.json b/backend/UniVerse.Api/appsettings.json index 0844925..afa262e 100644 --- a/backend/UniVerse.Api/appsettings.json +++ b/backend/UniVerse.Api/appsettings.json @@ -33,5 +33,16 @@ "System": "Information" } } + }, + "Email": { + "Smtp": { + "Host": "", + "Port": 587, + "EnableSsl": true, + "UserName": "", + "Password": "", + "FromAddress": "no-reply@universe.local", + "FromName": "UniVerse" + } } } diff --git a/backend/UniVerse.Application/DTOs/Notifications/NotificationDtos.cs b/backend/UniVerse.Application/DTOs/Notifications/NotificationDtos.cs new file mode 100644 index 0000000..f98a2ed --- /dev/null +++ b/backend/UniVerse.Application/DTOs/Notifications/NotificationDtos.cs @@ -0,0 +1,33 @@ +namespace UniVerse.Application.DTOs.Notifications; + +public static class NotificationChannels +{ + public const string Email = "email"; +} + +public record NotificationMessage( + string Channel, + string Recipient, + string Subject, + string Body, + string? RecipientName = null, + IReadOnlyDictionary? Metadata = null); + +public record SendNotificationRequest( + string Channel, + string Recipient, + string Subject, + string Body, + string? RecipientName = null, + IReadOnlyDictionary? Metadata = null); + +public record ScheduleNotificationRequest( + string Channel, + string Recipient, + string Subject, + string Body, + DateTimeOffset SendAt, + string? RecipientName = null, + IReadOnlyDictionary? Metadata = null); + +public record ScheduledNotificationResponse(string JobId, DateTimeOffset SendAt); diff --git a/backend/UniVerse.Application/Interfaces/IAuthService.cs b/backend/UniVerse.Application/Interfaces/IAuthService.cs index c0d615b..ee0d1d4 100644 --- a/backend/UniVerse.Application/Interfaces/IAuthService.cs +++ b/backend/UniVerse.Application/Interfaces/IAuthService.cs @@ -5,8 +5,8 @@ namespace UniVerse.Application.Interfaces; public interface IAuthService { - Task LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null); - Task DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role); + Task LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null); + Task DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role, string? ipAddress = null); Task RefreshTokenAsync(string refreshToken); Task RevokeRefreshTokenAsync(string refreshToken); Task GetCurrentUserAsync(int userId); diff --git a/backend/UniVerse.Application/Interfaces/INotificationProvider.cs b/backend/UniVerse.Application/Interfaces/INotificationProvider.cs new file mode 100644 index 0000000..383a1e2 --- /dev/null +++ b/backend/UniVerse.Application/Interfaces/INotificationProvider.cs @@ -0,0 +1,9 @@ +using UniVerse.Application.DTOs.Notifications; + +namespace UniVerse.Application.Interfaces; + +public interface INotificationProvider +{ + string Channel { get; } + Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default); +} diff --git a/backend/UniVerse.Application/Interfaces/INotificationScheduler.cs b/backend/UniVerse.Application/Interfaces/INotificationScheduler.cs new file mode 100644 index 0000000..0dde445 --- /dev/null +++ b/backend/UniVerse.Application/Interfaces/INotificationScheduler.cs @@ -0,0 +1,8 @@ +using UniVerse.Application.DTOs.Notifications; + +namespace UniVerse.Application.Interfaces; + +public interface INotificationScheduler +{ + Task ScheduleAsync(NotificationMessage message, DateTimeOffset sendAt, CancellationToken cancellationToken = default); +} diff --git a/backend/UniVerse.Application/Interfaces/INotificationService.cs b/backend/UniVerse.Application/Interfaces/INotificationService.cs new file mode 100644 index 0000000..8b83c4d --- /dev/null +++ b/backend/UniVerse.Application/Interfaces/INotificationService.cs @@ -0,0 +1,9 @@ +using UniVerse.Application.DTOs.Notifications; + +namespace UniVerse.Application.Interfaces; + +public interface INotificationService +{ + Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default); + Task ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default); +} diff --git a/backend/UniVerse.Infrastructure/Notifications/EmailNotificationOptions.cs b/backend/UniVerse.Infrastructure/Notifications/EmailNotificationOptions.cs new file mode 100644 index 0000000..378e2dd --- /dev/null +++ b/backend/UniVerse.Infrastructure/Notifications/EmailNotificationOptions.cs @@ -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; } +} diff --git a/backend/UniVerse.Infrastructure/Notifications/EmailNotificationProvider.cs b/backend/UniVerse.Infrastructure/Notifications/EmailNotificationProvider.cs new file mode 100644 index 0000000..008da31 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Notifications/EmailNotificationProvider.cs @@ -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 _logger; + + public EmailNotificationProvider(IOptions options, ILogger 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."); + } +} diff --git a/backend/UniVerse.Infrastructure/Notifications/NotificationJob.cs b/backend/UniVerse.Infrastructure/Notifications/NotificationJob.cs new file mode 100644 index 0000000..62b735b --- /dev/null +++ b/backend/UniVerse.Infrastructure/Notifications/NotificationJob.cs @@ -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 _logger; + + public NotificationJob(INotificationService notifications, ILogger 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(payload) + ?? throw new InvalidOperationException("Scheduled notification payload cannot be deserialized."); + + await _notifications.SendAsync(message, context.CancellationToken); + } +} diff --git a/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs b/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs new file mode 100644 index 0000000..5057fa8 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs @@ -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 _providers; + private readonly INotificationScheduler _scheduler; + private readonly ILogger _logger; + + public NotificationService( + IEnumerable providers, + INotificationScheduler scheduler, + ILogger 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 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); + } +} diff --git a/backend/UniVerse.Infrastructure/Notifications/QuartzNotificationScheduler.cs b/backend/UniVerse.Infrastructure/Notifications/QuartzNotificationScheduler.cs new file mode 100644 index 0000000..1f457ef --- /dev/null +++ b/backend/UniVerse.Infrastructure/Notifications/QuartzNotificationScheduler.cs @@ -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 _logger; + + public QuartzNotificationScheduler(ISchedulerFactory schedulerFactory, ILogger logger) + { + _schedulerFactory = schedulerFactory; + _logger = logger; + } + + public async Task 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() + .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); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/AuthService.cs b/backend/UniVerse.Infrastructure/Services/AuthService.cs index 94ff77d..40bd25e 100644 --- a/backend/UniVerse.Infrastructure/Services/AuthService.cs +++ b/backend/UniVerse.Infrastructure/Services/AuthService.cs @@ -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 _logger; - public AuthService(AppDbContext db, IConfiguration config, IGamificationService gamification) + public AuthService( + AppDbContext db, + IConfiguration config, + IGamificationService gamification, + INotificationService notifications, + ILogger logger) { _db = db; _config = config; _gamification = gamification; + _notifications = notifications; + _logger = logger; } - public async Task LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null) + public async Task 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 DevLoginAsync(string email, string? displayName, UserRole role) + public async Task 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 + { + ["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( diff --git a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj index 9f3f72c..fa24205 100644 --- a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj +++ b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj @@ -12,6 +12,7 @@ + diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index a2c3dc8..7856e49 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -33,6 +33,14 @@ services: - ModeusApi:BaseUrl=${MODEUS_API_BASE_URL} - ModeusApi:ApiKey=${MODEUS_API_KEY} + - Email:Smtp:Host=${EMAIL_SMTP_HOST} + - Email:Smtp:Port=${EMAIL_SMTP_PORT:-587} + - Email:Smtp:EnableSsl=${EMAIL_SMTP_ENABLE_SSL:-true} + - Email:Smtp:UserName=${EMAIL_SMTP_USERNAME} + - Email:Smtp:Password=${EMAIL_SMTP_PASSWORD} + - Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local} + - Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse} + - Gamification:XpThresholds=${GAMIFICATION_XP_THRESHOLDS:-[0, 100, 300, 600, 1000, 1500, 2500, 4000]} - ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE:-universe};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD} diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 7000317..e939f2a 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -35,6 +35,14 @@ services: - ModeusApi:BaseUrl=${MODEUS_API_BASE_URL} - ModeusApi:ApiKey=${MODEUS_API_KEY} + - Email:Smtp:Host=${EMAIL_SMTP_HOST} + - Email:Smtp:Port=${EMAIL_SMTP_PORT:-587} + - Email:Smtp:EnableSsl=${EMAIL_SMTP_ENABLE_SSL:-true} + - Email:Smtp:UserName=${EMAIL_SMTP_USERNAME} + - Email:Smtp:Password=${EMAIL_SMTP_PASSWORD} + - Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local} + - Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse} + - Gamification:XpThresholds=${GAMIFICATION_XP_THRESHOLDS:-[0, 100, 300, 600, 1000, 1500, 2500, 4000]} - ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}