- Added JWT configuration to appsettings.json for secure token handling. - Updated config.json to include OAuth provider details for Microsoft, Google, and PocketId. - Added Microsoft icon SVG for UI representation. - Refactored app.config.ts to use a custom AuthInterceptor for managing access tokens. - Enhanced auth route guard to handle asynchronous authentication checks. - Created new auth models for structured request and response handling. - Developed a callback component to manage user login states and transitions. - Updated side-login component to support multiple OAuth providers with loading states. - Implemented authentication service methods for handling OAuth login flows and token management. - Added error handling and user feedback for authentication processes.
273 lines
11 KiB
C#
273 lines
11 KiB
C#
using Api.Models;
|
|
using Api.Models.DTOs;
|
|
using Api.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Api.Controllers
|
|
{
|
|
[ApiController]
|
|
[Route("api/[controller]")]
|
|
public class AuthController : ControllerBase
|
|
{
|
|
private readonly AppDbContext _context;
|
|
private readonly IOAuthValidationService _oauthValidationService;
|
|
private readonly IJwtService _jwtService;
|
|
private readonly ILogger<AuthController> _logger;
|
|
|
|
public AuthController(
|
|
AppDbContext context,
|
|
IOAuthValidationService oauthValidationService,
|
|
IJwtService jwtService,
|
|
ILogger<AuthController> logger)
|
|
{
|
|
_context = context;
|
|
_oauthValidationService = oauthValidationService;
|
|
_jwtService = jwtService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticates a user with an OAuth ID token and returns a custom access token
|
|
/// </summary>
|
|
/// <param name="request">Authentication request containing ID token and provider</param>
|
|
/// <returns>Custom access token and user information</returns>
|
|
[HttpPost("authenticate")]
|
|
public async Task<ActionResult<AuthenticateResponse>> AuthenticateAsync([FromBody] AuthenticateRequest request)
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
return BadRequest(ModelState);
|
|
}
|
|
|
|
try
|
|
{
|
|
// Validate the ID token with the specified provider
|
|
var (isValid, principal, errorMessage) = await _oauthValidationService
|
|
.ValidateIdTokenAsync(request.IdToken, request.Provider);
|
|
|
|
if (!isValid || principal == null)
|
|
{
|
|
_logger.LogWarning("Invalid ID token for provider {Provider}: {Error}", request.Provider, errorMessage);
|
|
return BadRequest(new { error = "invalid_token", message = errorMessage ?? "Invalid ID token" });
|
|
}
|
|
|
|
// Extract user information from the validated token
|
|
var userInfo = request.Provider.ToLowerInvariant() == "microsoft"
|
|
? await _oauthValidationService.ExtractUserInfoAsync(principal, request.Provider, request.IdToken, request.AccessToken)
|
|
: _oauthValidationService.ExtractUserInfo(principal, request.Provider);
|
|
|
|
if (string.IsNullOrEmpty(userInfo.Email))
|
|
{
|
|
_logger.LogWarning("No email found in {Provider} token", request.Provider);
|
|
return BadRequest(new { error = "invalid_token", message = "Email claim is required" });
|
|
}
|
|
|
|
// Parse the provider enum
|
|
if (!Enum.TryParse<OAuthProvider>(request.Provider, true, out var providerEnum))
|
|
{
|
|
return BadRequest(new { error = "invalid_provider", message = $"Unsupported provider: {request.Provider}" });
|
|
}
|
|
|
|
// Find or create user
|
|
var (user, isNewUser) = await FindOrCreateUserAsync(userInfo, providerEnum);
|
|
|
|
// Update last login time
|
|
user.LastLoginAt = DateTime.UtcNow;
|
|
|
|
// Update or create OAuth provider record
|
|
await UpdateUserOAuthProviderAsync(user, userInfo, providerEnum);
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
// Generate custom access token
|
|
var accessToken = _jwtService.GenerateToken(user);
|
|
var expiresAt = _jwtService.GetTokenExpiration(accessToken);
|
|
|
|
// Prepare response
|
|
var userProfile = new UserProfile
|
|
{
|
|
Id = user.Id,
|
|
Email = user.Email,
|
|
FirstName = user.FirstName,
|
|
LastName = user.LastName,
|
|
ProfilePictureUrl = user.ProfilePictureUrl,
|
|
CreatedAt = user.CreatedAt,
|
|
LastLoginAt = user.LastLoginAt,
|
|
Providers = user.OAuthProviders.Select(p => p.Provider.ToString()).ToList()
|
|
};
|
|
|
|
var response = new AuthenticateResponse
|
|
{
|
|
AccessToken = accessToken,
|
|
ExpiresAt = expiresAt,
|
|
User = userProfile,
|
|
IsNewUser = isNewUser
|
|
};
|
|
|
|
_logger.LogInformation("User {Email} authenticated successfully with {Provider} (New: {IsNew})",
|
|
user.Email, request.Provider, isNewUser);
|
|
|
|
return Ok(response);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Authentication failed for provider {Provider}", request.Provider);
|
|
return StatusCode(500, new { error = "internal_error", message = "Authentication failed" });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current user's profile information
|
|
/// </summary>
|
|
/// <returns>User profile information</returns>
|
|
[HttpGet("me")]
|
|
[Authorize]
|
|
public async Task<ActionResult<UserProfile>> GetCurrentUserAsync()
|
|
{
|
|
try
|
|
{
|
|
var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
|
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId))
|
|
{
|
|
return Unauthorized(new { error = "invalid_token", message = "User ID not found in token" });
|
|
}
|
|
|
|
var user = await _context.Users
|
|
.Include(u => u.OAuthProviders)
|
|
.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive);
|
|
|
|
if (user == null)
|
|
{
|
|
return NotFound(new { error = "user_not_found", message = "User not found or inactive" });
|
|
}
|
|
|
|
var userProfile = new UserProfile
|
|
{
|
|
Id = user.Id,
|
|
Email = user.Email,
|
|
FirstName = user.FirstName,
|
|
LastName = user.LastName,
|
|
ProfilePictureUrl = user.ProfilePictureUrl,
|
|
CreatedAt = user.CreatedAt,
|
|
LastLoginAt = user.LastLoginAt,
|
|
Providers = user.OAuthProviders.Select(p => p.Provider.ToString()).ToList()
|
|
};
|
|
|
|
return Ok(userProfile);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error retrieving current user profile");
|
|
return StatusCode(500, new { error = "internal_error", message = "Failed to retrieve user profile" });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Revokes the current access token (logout)
|
|
/// </summary>
|
|
/// <returns>Success message</returns>
|
|
[HttpPost("logout")]
|
|
[Authorize]
|
|
public IActionResult Logout()
|
|
{
|
|
// In a real application, you might want to maintain a blacklist of revoked tokens
|
|
// For now, we'll just return success as JWT tokens are stateless
|
|
_logger.LogInformation("User logged out");
|
|
return Ok(new { message = "Logged out successfully" });
|
|
}
|
|
|
|
private async Task<(User User, bool IsNewUser)> FindOrCreateUserAsync(
|
|
(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) userInfo,
|
|
OAuthProvider provider)
|
|
{
|
|
var existingUser = await _context.Users
|
|
.Include(u => u.OAuthProviders)
|
|
.FirstOrDefaultAsync(u => u.Email == userInfo.Email && u.IsActive);
|
|
|
|
if (existingUser != null)
|
|
{
|
|
// Update user information if it has changed
|
|
var hasChanges = false;
|
|
|
|
if (!string.IsNullOrEmpty(userInfo.FirstName) && existingUser.FirstName != userInfo.FirstName)
|
|
{
|
|
existingUser.FirstName = userInfo.FirstName;
|
|
hasChanges = true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(userInfo.LastName) && existingUser.LastName != userInfo.LastName)
|
|
{
|
|
existingUser.LastName = userInfo.LastName;
|
|
hasChanges = true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(userInfo.ProfilePictureUrl) && existingUser.ProfilePictureUrl != userInfo.ProfilePictureUrl)
|
|
{
|
|
existingUser.ProfilePictureUrl = userInfo.ProfilePictureUrl;
|
|
hasChanges = true;
|
|
}
|
|
|
|
if (hasChanges)
|
|
{
|
|
_logger.LogInformation("Updated user information for {Email}", userInfo.Email);
|
|
}
|
|
|
|
return (existingUser, false);
|
|
}
|
|
|
|
// Create new user
|
|
var newUser = new User
|
|
{
|
|
Email = userInfo.Email,
|
|
FirstName = userInfo.FirstName,
|
|
LastName = userInfo.LastName,
|
|
ProfilePictureUrl = userInfo.ProfilePictureUrl,
|
|
CreatedAt = DateTime.UtcNow,
|
|
IsActive = true
|
|
};
|
|
|
|
_context.Users.Add(newUser);
|
|
_logger.LogInformation("Created new user for {Email}", userInfo.Email);
|
|
|
|
return (newUser, true);
|
|
}
|
|
|
|
private async Task UpdateUserOAuthProviderAsync(
|
|
User user,
|
|
(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) userInfo,
|
|
OAuthProvider provider)
|
|
{
|
|
var existingProvider = user.OAuthProviders
|
|
.FirstOrDefault(p => p.Provider == provider);
|
|
|
|
if (existingProvider != null)
|
|
{
|
|
// Update existing provider record
|
|
existingProvider.ProviderEmail = userInfo.Email;
|
|
existingProvider.ProviderName = $"{userInfo.FirstName} {userInfo.LastName}".Trim();
|
|
existingProvider.LastUsedAt = DateTime.UtcNow;
|
|
}
|
|
else
|
|
{
|
|
// Create new provider record
|
|
var newProvider = new UserOAuthProvider
|
|
{
|
|
UserId = user.Id,
|
|
Provider = provider,
|
|
ProviderId = userInfo.ProviderId,
|
|
ProviderEmail = userInfo.Email,
|
|
ProviderName = $"{userInfo.FirstName} {userInfo.LastName}".Trim(),
|
|
CreatedAt = DateTime.UtcNow,
|
|
LastUsedAt = DateTime.UtcNow
|
|
};
|
|
|
|
user.OAuthProviders.Add(newProvider);
|
|
_logger.LogInformation("Added {Provider} OAuth provider for user {Email}", provider, user.Email);
|
|
}
|
|
|
|
await Task.CompletedTask; // Make the method actually async
|
|
}
|
|
}
|
|
} |