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

252 lines
8.6 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.IdentityModel.Tokens;
using UniVerse.Application.DTOs.Auth;
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;
public AuthService(AppDbContext db, IConfiguration config, IGamificationService gamification)
{
_db = db;
_config = config;
_gamification = gamification;
}
public async Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = 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 authentication is not configured (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 authentication failed: {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 not found in Microsoft token claims.");
// Automatically provision user
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
if (user == null)
{
user = new User
{
Email = email,
DisplayName = name ?? email.Split('@')[0],
Role = UserRole.Student, // Default role
IsActive = true
};
_db.Users.Add(user);
await _db.SaveChangesAsync();
// Create corresponding profile
_db.StudentProfiles.Add(new StudentProfile { UserId = user.Id });
await _db.SaveChangesAsync();
}
else if (!user.IsActive)
{
throw new ForbiddenException("Account is deactivated.");
}
var accessToken = GenerateAccessToken(user);
var refreshToken = await GenerateRefreshTokenAsync(user.Id);
return new AuthResult(
new AuthResponse(
accessToken,
DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()),
user.ToAuthDto()
),
refreshToken
);
}
public async Task<AuthResult> DevLoginAsync(string email, string? displayName, UserRole role)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
if (user == null)
{
user = new User
{
Email = email,
DisplayName = displayName ?? email.Split('@')[0],
Role = role,
IsActive = true
};
_db.Users.Add(user);
await _db.SaveChangesAsync();
// Create profile based on role
if (role == UserRole.Student)
{
_db.StudentProfiles.Add(new StudentProfile { UserId = user.Id });
await _db.SaveChangesAsync();
}
else if (role == UserRole.Teacher)
{
_db.TeacherProfiles.Add(new TeacherProfile { UserId = user.Id });
await _db.SaveChangesAsync();
}
}
if (!user.IsActive)
throw new ForbiddenException("Account is deactivated.");
var accessToken = GenerateAccessToken(user);
var refreshToken = await GenerateRefreshTokenAsync(user.Id);
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)
.FirstOrDefaultAsync(rt => rt.Token == refreshToken);
if (token == null || !token.IsActive)
throw new ForbiddenException("Invalid or expired refresh token.");
// 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.FindAsync(userId)
?? throw new NotFoundException("User", userId);
return user.ToDto(_gamification.CalculateLevel(user.Xp));
}
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[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(ClaimTypes.Role, user.Role.ToString()),
new Claim("display_name", user.DisplayName ?? "")
};
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<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");
}