feat: Implement OAuth2 authentication with Microsoft, Google, and PocketId
- 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.
This commit is contained in:
273
Api/Controllers/AuthController.cs
Normal file
273
Api/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,273 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user