811b6ef51a
Backend CI / build-and-test (push) Successful in 52s
Frontend CI / build-and-check (push) Failing after 5m15s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 16s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m0s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 32s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 13s
323 lines
12 KiB
C#
323 lines
12 KiB
C#
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("Неверный или просроченный токен обновления.");
|
||
|
||
// 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<UserDto> GetCurrentUserAsync(int userId)
|
||
{
|
||
var user = await _db.Users
|
||
.Include(u => u.Roles)
|
||
.FirstOrDefaultAsync(u => u.Id == userId)
|
||
?? throw new NotFoundException("User", userId);
|
||
return user.ToDto(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");
|
||
}
|