Dev #11

Merged
serega404 merged 87 commits from dev into main 2026-05-25 03:22:55 +03:00
22 changed files with 464 additions and 16 deletions
Showing only changes of commit a0a0575a99 - Show all commits
+9
View File
@@ -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]
@@ -152,6 +152,12 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
yield return E("sync/status GET [Admin]", "GET", "api/v1/sync/status", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("sync/rooms POST [Admin]", "POST", "api/v1/sync/rooms", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("sync/employees POST [Admin]", "POST", "api/v1/sync/employees?fullname=test","Admin",forbidden: ["Student", "Teacher"]);
// ── Notifications — Admin only ─────────────────────────────────────────
yield return E("notifications/send POST [Admin]", "POST", "api/v1/notifications/send", "Admin", forbidden: ["Student", "Teacher"],
body: """{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello"}""");
yield return E("notifications/schedule POST [Admin]", "POST", "api/v1/notifications/schedule", "Admin", forbidden: ["Student", "Teacher"],
body: $$"""{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello","sendAt":"{{DateTimeOffset.UtcNow.AddMinutes(5):O}}"}""");
}
/// <summary>
@@ -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<Program>
ReplaceWithSubstitute<IScheduleSyncService>(services, CreateSyncServiceStub());
ReplaceWithSubstitute<ILlmAnalysisService>(services, Substitute.For<ILlmAnalysisService>());
ReplaceWithSubstitute<ILlmClient>(services, Substitute.For<ILlmClient>());
ReplaceWithSubstitute<INotificationService>(services, CreateNotificationServiceStub());
});
}
@@ -115,9 +117,9 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
new AuthResponse("access_token", DateTime.UtcNow.AddHours(1),
new UserAuthDto(1, "test@test.com", "Test User", UserRole.Student)),
"refresh_token");
stub.LoginWithMicrosoftAsync(Arg.Any<string>(), Arg.Any<string?>())
stub.LoginWithMicrosoftAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string?>())
.Returns(authResult);
stub.DevLoginAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<UserRole>())
stub.DevLoginAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<UserRole>(), Arg.Any<string?>())
.Returns(authResult);
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
stub.GetCurrentUserAsync(Arg.Any<int>())
@@ -125,6 +127,16 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
return stub;
}
private static INotificationService CreateNotificationServiceStub()
{
var stub = Substitute.For<INotificationService>();
stub.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
stub.ScheduleAsync(Arg.Any<ScheduleNotificationRequest>(), Arg.Any<CancellationToken>())
.Returns(new ScheduledNotificationResponse("test-job", DateTimeOffset.UtcNow.AddMinutes(5)));
return stub;
}
private static IUserService CreateUserServiceStub()
{
var stub = Substitute.For<IUserService>();
@@ -40,7 +40,7 @@ public class AuthController : ControllerBase
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AuthResponse>> 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<IWebHostEnvironment>().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
@@ -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;
/// <summary>Отправка и планирование уведомлений через доступные каналы.</summary>
[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;
}
/// <summary>Отправить уведомление немедленно.</summary>
/// <remarks>
/// Канал задаётся строкой, например `email`. Новые провайдеры добавляются через `INotificationProvider`.
/// </remarks>
/// <param name="request">Канал, получатель, тема и текст уведомления.</param>
/// <response code="202">Уведомление принято к отправке.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[HttpPost("send")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> 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();
}
/// <summary>Запланировать отложенную отправку уведомления через Quartz.NET.</summary>
/// <param name="request">Уведомление и момент отправки.</param>
/// <response code="202">Уведомление поставлено в очередь Quartz.NET.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[HttpPost("schedule")]
[ProducesResponseType(typeof(ScheduledNotificationResponse), StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ScheduledNotificationResponse>> Schedule([FromBody] ScheduleNotificationRequest request, CancellationToken cancellationToken)
{
var response = await _notifications.ScheduleAsync(request, cancellationToken);
return Accepted(response);
}
}
+13
View File
@@ -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<IGamificationService, GamificationService>();
builder.Services.AddScoped<IAchievementService, AchievementService>();
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
builder.Services.AddTransient<NotificationJob>();
builder.Services.Configure<EmailNotificationOptions>(builder.Configuration.GetSection("Email:Smtp"));
builder.Services.AddQuartz();
builder.Services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
// --- HTTP Clients ---
builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
+1
View File
@@ -24,6 +24,7 @@
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
</ItemGroup>
<ItemGroup>
+11
View File
@@ -33,5 +33,16 @@
"System": "Information"
}
}
},
"Email": {
"Smtp": {
"Host": "",
"Port": 587,
"EnableSsl": true,
"UserName": "",
"Password": "",
"FromAddress": "no-reply@universe.local",
"FromName": "UniVerse"
}
}
}
@@ -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<string, string>? Metadata = null);
public record SendNotificationRequest(
string Channel,
string Recipient,
string Subject,
string Body,
string? RecipientName = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public record ScheduleNotificationRequest(
string Channel,
string Recipient,
string Subject,
string Body,
DateTimeOffset SendAt,
string? RecipientName = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public record ScheduledNotificationResponse(string JobId, DateTimeOffset SendAt);
@@ -5,8 +5,8 @@ namespace UniVerse.Application.Interfaces;
public interface IAuthService
{
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null);
Task<AuthResult> DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role);
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null);
Task<AuthResult> DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role, string? ipAddress = null);
Task<AuthResult> RefreshTokenAsync(string refreshToken);
Task RevokeRefreshTokenAsync(string refreshToken);
Task<UserDto> GetCurrentUserAsync(int userId);
@@ -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);
}
@@ -0,0 +1,8 @@
using UniVerse.Application.DTOs.Notifications;
namespace UniVerse.Application.Interfaces;
public interface INotificationScheduler
{
Task<ScheduledNotificationResponse> ScheduleAsync(NotificationMessage message, DateTimeOffset sendAt, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,9 @@
using UniVerse.Application.DTOs.Notifications;
namespace UniVerse.Application.Interfaces;
public interface INotificationService
{
Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default);
Task<ScheduledNotificationResponse> ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default);
}
@@ -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>
+8
View File
@@ -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}
+8
View File
@@ -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}