Files
UniVerse/backend/UniVerse.Infrastructure/Services/AuthService.cs
T
serega404 038f0d89a6
gitea-push-docker.yml (push) Invalid workflow file
Backend CI / build-and-test (push) Successful in 3m34s
Frontend CI / build-and-check (push) Failing after 21s
feat: добавил отображение аватарки
2026-10-01 00:17:16 +03:00

350 lines
13 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 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<AuthService> _logger;
public AuthService(
AppDbContext db,
IConfiguration config,
IMicrosoftAuthClient microsoftAuth,
IGamificationService gamification,
INotificationService notifications,
ILogger<AuthService> logger)
{
_db = db;
_config = config;
_microsoftAuth = microsoftAuth;
_gamification = gamification;
_notifications = notifications;
_logger = logger;
}
public async Task<AuthResult> 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();
}
try
{
if (string.IsNullOrWhiteSpace(user.AvatarUrl))
await _microsoftAuth.SaveTokenCacheAsync(user.Id, result);
else
await _microsoftAuth.RemoveTokenCacheAsync(user.Id);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException)
{
_logger.LogWarning(ex, "Could not save Microsoft token cache for user {UserId}.", user.Id);
}
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 RemoveMicrosoftTokenCacheAsync(int userId)
{
try
{
await _microsoftAuth.RemoveTokenCacheAsync(userId);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Could not remove Microsoft token cache for user {UserId}.", userId);
}
}
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");
}