using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; 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; using UniVerse.Domain.Entities; using UniVerse.Domain.Enums; using UniVerse.Domain.Exceptions; using UniVerse.Infrastructure.Data; namespace UniVerse.Infrastructure.Services; public class AuthService : IAuthService { private readonly AppDbContext _db; private readonly IConfiguration _config; private readonly IMicrosoftAuthClient _microsoftAuth; private readonly IGamificationService _gamification; private readonly INotificationService _notifications; private readonly ILogger _logger; public AuthService( AppDbContext db, IConfiguration config, IMicrosoftAuthClient microsoftAuth, IGamificationService gamification, INotificationService notifications, ILogger logger) { _db = db; _config = config; _microsoftAuth = microsoftAuth; _gamification = gamification; _notifications = notifications; _logger = logger; } public async Task LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null) { var effectiveRedirectUri = redirectUri ?? _config["AzureAd:RedirectUri"] ?? "http://localhost:5173/auth/callback"; var result = await _microsoftAuth.ExchangeAuthorizationCodeAsync(authorizationCode, effectiveRedirectUri); // Parse claims directly from the ID token provided by Microsoft var handler = new JwtSecurityTokenHandler(); var idToken = handler.ReadJwtToken(result.IdToken); var email = idToken.Claims.FirstOrDefault(c => c.Type == "preferred_username" || c.Type == "email" || c.Type == ClaimTypes.Upn)?.Value; var name = idToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value; var microsoftSub = idToken.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Sub || c.Type == "sub")?.Value; if (string.IsNullOrEmpty(email)) throw new UnauthorizedException("Email не найден в токене Microsoft."); if (string.IsNullOrWhiteSpace(microsoftSub)) throw new UnauthorizedException("Sub ID не найден в токене Microsoft."); // Automatically provision user var user = await _db.Users .Include(u => u.Roles) .Include(u => u.TeacherProfile) .FirstOrDefaultAsync(u => u.MicrosoftId == microsoftSub); user ??= await _db.Users .Include(u => u.Roles) .Include(u => u.TeacherProfile) .FirstOrDefaultAsync(u => u.Email == email); if (user == null) { user = new User { Email = email, DisplayName = name ?? email.Split('@')[0], MicrosoftId = microsoftSub, IsActive = true }; _db.Users.Add(user); await _db.SaveChangesAsync(); user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = UserRole.Student }); _db.StudentProfiles.Add(new StudentProfile { UserId = user.Id }); await _db.SaveChangesAsync(); } else if (!user.IsActive) { throw new ForbiddenException("Аккаунт деактивирован."); } else { user.Email = email; user.DisplayName = name ?? user.DisplayName ?? email.Split('@')[0]; user.MicrosoftId = microsoftSub; user.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); } if (user.Roles.Count == 0) { user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = UserRole.Student }); await EnsureProfilesForRolesAsync(user.Id, [UserRole.Student]); await _db.SaveChangesAsync(); } var accessToken = GenerateAccessToken(user); var refreshToken = await GenerateRefreshTokenAsync(user.Id); await TrySendLoginNotificationAsync(user, ipAddress); return new AuthResult( new AuthResponse( accessToken, DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), user.ToAuthDto() ), refreshToken ); } public async Task DevLoginAsync(string email, string? displayName, IReadOnlyCollection roles, string? ipAddress = null) { var normalizedRoles = (roles.Count > 0 ? roles : [UserRole.Student]).Distinct().ToList(); var user = await _db.Users .Include(u => u.Roles) .FirstOrDefaultAsync(u => u.Email == email); if (user == null) { user = new User { Email = email, DisplayName = displayName ?? email.Split('@')[0], IsActive = true }; _db.Users.Add(user); await _db.SaveChangesAsync(); } var existing = user.Roles.Select(r => r.Role).ToHashSet(); var toRemove = user.Roles.Where(r => !normalizedRoles.Contains(r.Role)).ToList(); foreach (var item in toRemove) user.Roles.Remove(item); foreach (var role in normalizedRoles.Where(r => !existing.Contains(r))) user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = role }); await EnsureProfilesForRolesAsync(user.Id, normalizedRoles); await _db.SaveChangesAsync(); if (!user.IsActive) throw new ForbiddenException("Аккаунт деактивирован."); var accessToken = GenerateAccessToken(user); var refreshToken = await GenerateRefreshTokenAsync(user.Id); await TrySendLoginNotificationAsync(user, ipAddress); return new AuthResult( new AuthResponse( accessToken, DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), user.ToAuthDto() ), refreshToken ); } public async Task RefreshTokenAsync(string refreshToken) { var token = await _db.RefreshTokens .Include(rt => rt.User) .ThenInclude(u => u.Roles) .FirstOrDefaultAsync(rt => rt.Token == refreshToken); if (token == null || !token.IsActive) throw new ForbiddenException("Неверный или просроченный токен обновления."); if (!token.User.IsActive) { token.RevokedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); throw new ForbiddenException("Аккаунт деактивирован."); } // Revoke old token token.RevokedAt = DateTime.UtcNow; // Generate new tokens var accessToken = GenerateAccessToken(token.User); var newRefreshToken = await GenerateRefreshTokenAsync(token.UserId); await _db.SaveChangesAsync(); return new AuthResult( new AuthResponse( accessToken, DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), token.User.ToAuthDto() ), newRefreshToken ); } public async Task RevokeRefreshTokenAsync(string refreshToken) { var token = await _db.RefreshTokens.FirstOrDefaultAsync(rt => rt.Token == refreshToken); if (token != null && token.IsActive) { token.RevokedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); } } public async Task GetCurrentUserAsync(int userId) { var user = await _db.Users .Include(u => u.Roles) .FirstOrDefaultAsync(u => u.Id == userId) ?? throw new NotFoundException("User", userId); if (!user.IsActive) throw new ForbiddenException("Аккаунт деактивирован."); return user.ToCurrentUserDto(await _gamification.CalculateLevelAsync(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( Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new List { new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new(JwtRegisteredClaimNames.Email, user.Email), new("display_name", user.DisplayName ?? "") }; var roles = user.Roles.Select(r => r.Role).Distinct().ToList(); if (roles.Count == 0) roles.Add(UserRole.Student); claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role.ToString()))); var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], audience: _config["Jwt:Audience"], claims: claims, expires: DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), signingCredentials: creds ); return new JwtSecurityTokenHandler().WriteToken(token); } private async Task EnsureProfilesForRolesAsync(int userId, IReadOnlyCollection roles) { if (roles.Contains(UserRole.Student)) { var hasStudentProfile = await _db.StudentProfiles.AnyAsync(p => p.UserId == userId); if (!hasStudentProfile) _db.StudentProfiles.Add(new StudentProfile { UserId = userId }); } if (roles.Contains(UserRole.Teacher)) { var hasTeacherProfile = await _db.TeacherProfiles.AnyAsync(p => p.UserId == userId); if (!hasTeacherProfile) _db.TeacherProfiles.Add(new TeacherProfile { UserId = userId }); } } private async Task GenerateRefreshTokenAsync(int userId) { var randomBytes = RandomNumberGenerator.GetBytes(64); var tokenString = Convert.ToBase64String(randomBytes); var refreshToken = new RefreshToken { UserId = userId, Token = tokenString, ExpiresAt = DateTime.UtcNow.AddDays(GetRefreshTokenExpiration()), CreatedAt = DateTime.UtcNow }; _db.RefreshTokens.Add(refreshToken); await _db.SaveChangesAsync(); return tokenString; } private int GetAccessTokenExpiration() => int.Parse(_config["Jwt:AccessTokenExpirationMinutes"] ?? "30"); private int GetRefreshTokenExpiration() => int.Parse(_config["Jwt:RefreshTokenExpirationDays"] ?? "30"); }