Files
UniVerse/backend/UniVerse.Infrastructure/Services/AuthService.cs
T

333 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Identity.Client;
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 IGamificationService _gamification;
private readonly INotificationService _notifications;
private readonly ILogger<AuthService> _logger;
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, string? ipAddress = null)
{
var tenantId = _config["AzureAd:TenantId"];
var clientId = _config["AzureAd:ClientId"];
var clientSecret = _config["AzureAd:ClientSecret"];
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret).");
var effectiveRedirectUri = redirectUri
?? _config["AzureAd:RedirectUri"]
?? "http://localhost:5173/auth/callback";
var authority = $"{instance.TrimEnd('/')}/{tenantId}";
var app = ConfidentialClientApplicationBuilder.Create(clientId)
.WithClientSecret(clientSecret)
.WithAuthority(new Uri(authority))
.WithRedirectUri(effectiveRedirectUri)
.Build();
AuthenticationResult result;
try
{
result = await app.AcquireTokenByAuthorizationCode(new[] { "User.Read" }, authorizationCode)
.ExecuteAsync();
}
catch (MsalException ex)
{
throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}");
}
// 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;
if (string.IsNullOrEmpty(email))
throw new UnauthorizedException("Email не найден в токене Microsoft.");
// Automatically provision user
var user = await _db.Users
.Include(u => u.Roles)
.FirstOrDefaultAsync(u => u.Email == email);
if (user == null)
{
user = new User
{
Email = email,
DisplayName = name ?? email.Split('@')[0],
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("Аккаунт деактивирован.");
}
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<AuthResult> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<UserRole> 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<AuthResult> 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<CurrentUserDto> 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<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(
Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
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<UserRole> 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<string> 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");
}