242 lines
8.1 KiB
C#
242 lines
8.1 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)
|
|
{
|
|
var tenantId = _config["AzureAd:TenantId"];
|
|
var clientId = _config["AzureAd:ClientId"];
|
|
var clientSecret = _config["AzureAd:ClientSecret"];
|
|
|
|
var app = ConfidentialClientApplicationBuilder.Create(clientId)
|
|
.WithClientSecret(clientSecret)
|
|
.WithAuthority(new Uri($"https://login.microsoftonline.com/{tenantId}"))
|
|
.WithRedirectUri(_config["AzureAd:RedirectUri"] ?? "http://localhost:5173/auth/callback")
|
|
.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");
|
|
}
|