Compare commits
10 Commits
426b4c55fc
...
44560ac82d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44560ac82d | ||
|
|
74c466b56f | ||
|
|
74dfb95d99 | ||
|
|
441b00b510 | ||
|
|
89ac32cd07 | ||
|
|
1dcd23a776 | ||
|
|
f34d523413 | ||
|
|
c14f62849f | ||
|
|
66e6bc93f9 | ||
|
|
7b22f2d237 |
13
.vscode/mcp.json
vendored
Normal file
13
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"servers": {
|
||||
"my-mcp-server-93a33ffa": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://mcp.linear.app/sse"
|
||||
]
|
||||
}
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Cloud.RecaptchaEnterprise.V1" Version="2.18.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.18" NoWarn="NU1605" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.18" NoWarn="NU1605" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.18">
|
||||
@@ -21,6 +22,9 @@
|
||||
<PackageReference Include="Microsoft.Identity.Web.DownstreamApi" Version="3.10.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.18" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.18" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
483
Api/Controllers/UserController.cs
Normal file
483
Api/Controllers/UserController.cs
Normal file
@@ -0,0 +1,483 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Api.Models;
|
||||
using Api.Models.DTOs;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
public class UserController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<UserController> _logger;
|
||||
|
||||
public UserController(AppDbContext context, ILogger<UserController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all users with pagination support
|
||||
/// </summary>
|
||||
/// <param name="page">Page number (default: 1)</param>
|
||||
/// <param name="pageSize">Page size (default: 10, max: 100)</param>
|
||||
/// <param name="search">Search term for email, first name, or last name</param>
|
||||
/// <param name="isActive">Filter by active status</param>
|
||||
/// <returns>Paginated list of users</returns>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<object>> GetUsers(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 10,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] bool? isActive = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate pagination parameters
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Min(100, Math.Max(1, pageSize));
|
||||
|
||||
var query = _context.Users.Include(u => u.OAuthProviders).AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
query = query.Where(u => u.IsActive == isActive.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
{
|
||||
var searchTerm = search.ToLower();
|
||||
query = query.Where(u =>
|
||||
u.Email.ToLower().Contains(searchTerm) ||
|
||||
(u.FirstName != null && u.FirstName.ToLower().Contains(searchTerm)) ||
|
||||
(u.LastName != null && u.LastName.ToLower().Contains(searchTerm)));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var users = await query
|
||||
.OrderByDescending(u => u.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(u => new UserDto
|
||||
{
|
||||
Id = u.Id,
|
||||
Email = u.Email,
|
||||
FirstName = u.FirstName,
|
||||
LastName = u.LastName,
|
||||
ProfilePictureUrl = u.ProfilePictureUrl,
|
||||
CreatedAt = u.CreatedAt,
|
||||
LastLoginAt = u.LastLoginAt,
|
||||
IsActive = u.IsActive,
|
||||
OAuthProviders = u.OAuthProviders.Select(op => new UserOAuthProviderDto
|
||||
{
|
||||
Id = op.Id,
|
||||
Provider = op.Provider,
|
||||
ProviderId = op.ProviderId,
|
||||
ProviderEmail = op.ProviderEmail,
|
||||
ProviderName = op.ProviderName,
|
||||
CreatedAt = op.CreatedAt,
|
||||
LastUsedAt = op.LastUsedAt
|
||||
}).ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
users,
|
||||
pagination = new
|
||||
{
|
||||
page,
|
||||
pageSize,
|
||||
totalCount,
|
||||
totalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
|
||||
hasNext = page * pageSize < totalCount,
|
||||
hasPrevious = page > 1
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving users");
|
||||
return StatusCode(500, "Internal server error while retrieving users");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific user by ID
|
||||
/// </summary>
|
||||
/// <param name="id">User ID</param>
|
||||
/// <returns>User details</returns>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<UserDto>> GetUser(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _context.Users
|
||||
.Include(u => u.OAuthProviders)
|
||||
.FirstOrDefaultAsync(u => u.Id == id);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
|
||||
var userDto = new UserDto
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
ProfilePictureUrl = user.ProfilePictureUrl,
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
IsActive = user.IsActive,
|
||||
OAuthProviders = user.OAuthProviders.Select(op => new UserOAuthProviderDto
|
||||
{
|
||||
Id = op.Id,
|
||||
Provider = op.Provider,
|
||||
ProviderId = op.ProviderId,
|
||||
ProviderEmail = op.ProviderEmail,
|
||||
ProviderName = op.ProviderName,
|
||||
CreatedAt = op.CreatedAt,
|
||||
LastUsedAt = op.LastUsedAt
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Ok(userDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving user with ID {UserId}", id);
|
||||
return StatusCode(500, "Internal server error while retrieving user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current user's profile
|
||||
/// </summary>
|
||||
/// <returns>Current user's profile</returns>
|
||||
[HttpGet("me")]
|
||||
public async Task<ActionResult<UserDto>> GetCurrentUser()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userIdClaim = User.FindFirst("user_id")?.Value;
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return Unauthorized("Invalid user token");
|
||||
}
|
||||
|
||||
return await GetUser(userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving current user profile");
|
||||
return StatusCode(500, "Internal server error while retrieving current user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new user
|
||||
/// </summary>
|
||||
/// <param name="request">User creation request</param>
|
||||
/// <returns>Created user</returns>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<UserDto>> CreateUser([FromBody] CreateUserRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Check if user with this email already exists
|
||||
var existingUser = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Email == request.Email);
|
||||
|
||||
if (existingUser != null)
|
||||
{
|
||||
return Conflict($"User with email {request.Email} already exists");
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Email = request.Email,
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
ProfilePictureUrl = request.ProfilePictureUrl,
|
||||
IsActive = request.IsActive,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Users.Add(user);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Created new user with ID {UserId} and email {Email}", user.Id, user.Email);
|
||||
|
||||
var userDto = new UserDto
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
ProfilePictureUrl = user.ProfilePictureUrl,
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
IsActive = user.IsActive,
|
||||
OAuthProviders = new List<UserOAuthProviderDto>()
|
||||
};
|
||||
|
||||
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, userDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating user with email {Email}", request.Email);
|
||||
return StatusCode(500, "Internal server error while creating user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update a user
|
||||
/// </summary>
|
||||
/// <param name="id">User ID</param>
|
||||
/// <param name="request">User update request</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateUser(int id, [FromBody] UpdateUserRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var user = await _context.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
|
||||
// Update only provided fields
|
||||
if (request.FirstName != null)
|
||||
{
|
||||
user.FirstName = request.FirstName;
|
||||
}
|
||||
|
||||
if (request.LastName != null)
|
||||
{
|
||||
user.LastName = request.LastName;
|
||||
}
|
||||
|
||||
if (request.ProfilePictureUrl != null)
|
||||
{
|
||||
user.ProfilePictureUrl = request.ProfilePictureUrl;
|
||||
}
|
||||
|
||||
if (request.IsActive.HasValue)
|
||||
{
|
||||
user.IsActive = request.IsActive.Value;
|
||||
}
|
||||
|
||||
_context.Entry(user).State = EntityState.Modified;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated user with ID {UserId}", id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (!await _context.Users.AnyAsync(e => e.Id == id))
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating user with ID {UserId}", id);
|
||||
return StatusCode(500, "Internal server error while updating user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the current user's profile
|
||||
/// </summary>
|
||||
/// <param name="request">User update request</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpPut("me")]
|
||||
public async Task<IActionResult> UpdateCurrentUser([FromBody] UpdateUserRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userIdClaim = User.FindFirst("user_id")?.Value;
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return Unauthorized("Invalid user token");
|
||||
}
|
||||
|
||||
return await UpdateUser(userId, request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating current user profile");
|
||||
return StatusCode(500, "Internal server error while updating current user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a user (soft delete by setting IsActive to false)
|
||||
/// </summary>
|
||||
/// <param name="id">User ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteUser(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _context.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
|
||||
// Soft delete by setting IsActive to false
|
||||
user.IsActive = false;
|
||||
_context.Entry(user).State = EntityState.Modified;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Soft deleted user with ID {UserId}", id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting user with ID {UserId}", id);
|
||||
return StatusCode(500, "Internal server error while deleting user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permanently delete a user and all associated OAuth providers
|
||||
/// </summary>
|
||||
/// <param name="id">User ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpDelete("{id}/permanent")]
|
||||
public async Task<IActionResult> PermanentlyDeleteUser(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _context.Users
|
||||
.Include(u => u.OAuthProviders)
|
||||
.FirstOrDefaultAsync(u => u.Id == id);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
|
||||
// Remove all OAuth providers first due to foreign key constraints
|
||||
_context.UserOAuthProviders.RemoveRange(user.OAuthProviders);
|
||||
|
||||
// Remove the user
|
||||
_context.Users.Remove(user);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Permanently deleted user with ID {UserId} and {ProviderCount} OAuth providers",
|
||||
id, user.OAuthProviders.Count);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error permanently deleting user with ID {UserId}", id);
|
||||
return StatusCode(500, "Internal server error while permanently deleting user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reactivate a soft-deleted user
|
||||
/// </summary>
|
||||
/// <param name="id">User ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpPost("{id}/reactivate")]
|
||||
public async Task<IActionResult> ReactivateUser(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _context.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
|
||||
user.IsActive = true;
|
||||
_context.Entry(user).State = EntityState.Modified;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Reactivated user with ID {UserId}", id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error reactivating user with ID {UserId}", id);
|
||||
return StatusCode(500, "Internal server error while reactivating user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get user statistics
|
||||
/// </summary>
|
||||
/// <returns>User statistics</returns>
|
||||
[HttpGet("statistics")]
|
||||
public async Task<ActionResult<object>> GetUserStatistics()
|
||||
{
|
||||
try
|
||||
{
|
||||
var totalUsers = await _context.Users.CountAsync();
|
||||
var activeUsers = await _context.Users.CountAsync(u => u.IsActive);
|
||||
var inactiveUsers = totalUsers - activeUsers;
|
||||
var usersWithLogin = await _context.Users.CountAsync(u => u.LastLoginAt != null);
|
||||
var recentUsers = await _context.Users.CountAsync(u => u.CreatedAt >= DateTime.UtcNow.AddDays(-30));
|
||||
|
||||
var providerStats = await _context.UserOAuthProviders
|
||||
.GroupBy(op => op.Provider)
|
||||
.Select(g => new
|
||||
{
|
||||
Provider = g.Key.ToString(),
|
||||
Count = g.Count(),
|
||||
UniqueUsers = g.Select(op => op.UserId).Distinct().Count()
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
inactiveUsers,
|
||||
usersWithLogin,
|
||||
recentUsers,
|
||||
providerStatistics = providerStats
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving user statistics");
|
||||
return StatusCode(500, "Internal server error while retrieving statistics");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,8 +59,9 @@ namespace Api.Controllers
|
||||
if (string.IsNullOrWhiteSpace(message.Message) && string.IsNullOrWhiteSpace(message.Subject))
|
||||
return BadRequest("Message or Subject is required.");
|
||||
|
||||
if(ReCaptchaAssessment.CheckToken(message.RecaptchaToken, out string reason) == false)
|
||||
return BadRequest($"ReCaptcha validation failed: {reason}");
|
||||
// optional: validate ReCaptcha token
|
||||
// if(ReCaptchaAssessment.CheckToken(message.RecaptchaToken, out string reason) == false)
|
||||
// return BadRequest($"ReCaptcha validation failed: {reason}");
|
||||
|
||||
_context.WebMessages.Add(message);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
62
Api/Helpers/ReCapthchaAssessment.cs
Normal file
62
Api/Helpers/ReCapthchaAssessment.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using Google.Api.Gax.ResourceNames;
|
||||
using Google.Cloud.RecaptchaEnterprise.V1;
|
||||
|
||||
namespace Api.Helpers
|
||||
{
|
||||
public static class ReCaptchaAssessment
|
||||
{
|
||||
// Checks the supplied token with Recaptcha Enterprise and returns true if valid.
|
||||
// Outputs a short reason when false (invalid reason or exception message).
|
||||
public static bool CheckToken(string token, out string reason, string projectId = "webserverfarm", string recaptchaKey = "6Lf6df0rAAAAAMXcAx1umneXl1QJo9rTrflpWCvB")
|
||||
{
|
||||
reason = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
reason = "empty-token";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = RecaptchaEnterpriseServiceClient.Create();
|
||||
|
||||
var request = new CreateAssessmentRequest
|
||||
{
|
||||
Parent = $"projects/{projectId}",
|
||||
Assessment = new Assessment
|
||||
{
|
||||
Event = new Event
|
||||
{
|
||||
SiteKey = recaptchaKey,
|
||||
Token = token
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = client.CreateAssessment(request);
|
||||
|
||||
if (response?.TokenProperties == null)
|
||||
{
|
||||
reason = "missing-token-properties";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.TokenProperties.Valid)
|
||||
{
|
||||
reason = response.TokenProperties.InvalidReason.ToString();
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = "valid";
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
reason = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
Api/Migrations/20251107172421_AddUserAuthentication.Designer.cs
generated
Normal file
189
Api/Migrations/20251107172421_AddUserAuthentication.Designer.cs
generated
Normal file
@@ -0,0 +1,189 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20251107172421_AddUserAuthentication")]
|
||||
partial class AddUserAuthentication
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.18")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Api.Models.Product", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Price")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Products");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Sample Product",
|
||||
Price = 9.99m
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProfilePictureUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.UserOAuthProvider", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Provider")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ProviderEmail")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("Provider", "ProviderId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserOAuthProviders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WebMessage", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Surname")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WebMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.UserOAuthProvider", b =>
|
||||
{
|
||||
b.HasOne("Api.Models.User", "User")
|
||||
.WithMany("OAuthProviders")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OAuthProviders");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Api/Migrations/20251107172421_AddUserAuthentication.cs
Normal file
86
Api/Migrations/20251107172421_AddUserAuthentication.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserAuthentication : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Email = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
|
||||
FirstName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
|
||||
LastName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
|
||||
ProfilePictureUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
LastLoginAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserOAuthProviders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
UserId = table.Column<int>(type: "int", nullable: false),
|
||||
Provider = table.Column<int>(type: "int", nullable: false),
|
||||
ProviderId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
|
||||
ProviderEmail = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
|
||||
ProviderName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
LastUsedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserOAuthProviders", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserOAuthProviders_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserOAuthProviders_Provider_ProviderId",
|
||||
table: "UserOAuthProviders",
|
||||
columns: new[] { "Provider", "ProviderId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserOAuthProviders_UserId",
|
||||
table: "UserOAuthProviders",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_Email",
|
||||
table: "Users",
|
||||
column: "Email",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserOAuthProviders");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@@ -48,6 +49,91 @@ namespace Api.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProfilePictureUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.UserOAuthProvider", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Provider")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ProviderEmail")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("Provider", "ProviderId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserOAuthProviders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WebMessage", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -78,6 +164,22 @@ namespace Api.Migrations
|
||||
|
||||
b.ToTable("WebMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.UserOAuthProvider", b =>
|
||||
{
|
||||
b.HasOne("Api.Models.User", "User")
|
||||
.WithMany("OAuthProviders")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OAuthProviders");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,40 @@ namespace Api.Models
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
public DbSet<Product> Products { get; set; }
|
||||
|
||||
public DbSet<WebMessage> WebMessages { get; set; }
|
||||
public DbSet<User> Users { get; set; }
|
||||
public DbSet<UserOAuthProvider> UserOAuthProviders { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Configure User entity
|
||||
modelBuilder.Entity<User>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Email).IsUnique();
|
||||
entity.Property(e => e.Email).IsRequired().HasMaxLength(255);
|
||||
entity.Property(e => e.FirstName).HasMaxLength(255);
|
||||
entity.Property(e => e.LastName).HasMaxLength(255);
|
||||
entity.Property(e => e.ProfilePictureUrl).HasMaxLength(500);
|
||||
});
|
||||
|
||||
// Configure UserOAuthProvider entity
|
||||
modelBuilder.Entity<UserOAuthProvider>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.Provider, e.ProviderId }).IsUnique();
|
||||
entity.Property(e => e.ProviderId).IsRequired().HasMaxLength(255);
|
||||
entity.Property(e => e.ProviderEmail).HasMaxLength(255);
|
||||
entity.Property(e => e.ProviderName).HasMaxLength(255);
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany(u => u.OAuthProviders)
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Seed data
|
||||
modelBuilder.Entity<Product>().HasData(
|
||||
new Product { Id = 1, Name = "Sample Product", Price = 9.99M }
|
||||
|
||||
117
Api/Models/DTOs/AuthenticationDtos.cs
Normal file
117
Api/Models/DTOs/AuthenticationDtos.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Api.Models.DTOs
|
||||
{
|
||||
public class AuthenticateRequest
|
||||
{
|
||||
[Required]
|
||||
public string IdToken { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Provider { get; set; } = string.Empty; // "Microsoft", "Google", "PocketId"
|
||||
|
||||
/// <summary>
|
||||
/// Optional access token for API calls (e.g., Microsoft Graph)
|
||||
/// </summary>
|
||||
public string? AccessToken { get; set; }
|
||||
}
|
||||
|
||||
public class AuthenticateResponse
|
||||
{
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public UserProfile User { get; set; } = null!;
|
||||
public bool IsNewUser { get; set; }
|
||||
}
|
||||
|
||||
public class UserProfile
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? ProfilePictureUrl { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public List<string> Providers { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
// User CRUD DTOs
|
||||
public class CreateUserRequest
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[StringLength(255)]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(255)]
|
||||
public string? FirstName { get; set; }
|
||||
|
||||
[StringLength(255)]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? ProfilePictureUrl { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public class UpdateUserRequest
|
||||
{
|
||||
[StringLength(255)]
|
||||
public string? FirstName { get; set; }
|
||||
|
||||
[StringLength(255)]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? ProfilePictureUrl { get; set; }
|
||||
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class UserDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? ProfilePictureUrl { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<UserOAuthProviderDto> OAuthProviders { get; set; } = new List<UserOAuthProviderDto>();
|
||||
}
|
||||
|
||||
public class UserOAuthProviderDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public OAuthProvider Provider { get; set; }
|
||||
public string ProviderId { get; set; } = string.Empty;
|
||||
public string? ProviderEmail { get; set; }
|
||||
public string? ProviderName { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
}
|
||||
|
||||
public class JwtSettings
|
||||
{
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
public string Audience { get; set; } = string.Empty;
|
||||
public int ExpirationMinutes { get; set; } = 60;
|
||||
}
|
||||
|
||||
public class OAuthProviderSettings
|
||||
{
|
||||
public Dictionary<string, ProviderConfig> Providers { get; set; } = new Dictionary<string, ProviderConfig>();
|
||||
}
|
||||
|
||||
public class ProviderConfig
|
||||
{
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string? ClientSecret { get; set; }
|
||||
public List<string> ValidAudiences { get; set; } = new List<string>();
|
||||
}
|
||||
}
|
||||
72
Api/Models/User.cs
Normal file
72
Api/Models/User.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Api.Models
|
||||
{
|
||||
public class User
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(255)]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(255)]
|
||||
public string? FirstName { get; set; }
|
||||
|
||||
[StringLength(255)]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? ProfilePictureUrl { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
// Navigation property for OAuth providers
|
||||
public virtual ICollection<UserOAuthProvider> OAuthProviders { get; set; } = new List<UserOAuthProvider>();
|
||||
}
|
||||
|
||||
public class UserOAuthProvider
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public int UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
public OAuthProvider Provider { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(255)]
|
||||
public string ProviderId { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(255)]
|
||||
public string? ProviderEmail { get; set; }
|
||||
|
||||
[StringLength(255)]
|
||||
public string? ProviderName { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey("UserId")]
|
||||
public virtual User User { get; set; } = null!;
|
||||
}
|
||||
|
||||
public enum OAuthProvider
|
||||
{
|
||||
Microsoft = 1,
|
||||
Google = 2,
|
||||
PocketId = 3
|
||||
// Add more providers as needed
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Identity.Abstractions;
|
||||
using Microsoft.Identity.Web;
|
||||
using Microsoft.Identity.Web.Resource;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Text;
|
||||
using Api.Services;
|
||||
|
||||
namespace Api
|
||||
{
|
||||
@@ -17,6 +20,15 @@ namespace Api
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
|
||||
// Configure JWT authentication for custom tokens
|
||||
var jwtSettings = builder.Configuration.GetSection("Jwt");
|
||||
var secretKey = jwtSettings["SecretKey"];
|
||||
if (string.IsNullOrEmpty(secretKey))
|
||||
{
|
||||
throw new InvalidOperationException("JWT SecretKey must be configured");
|
||||
}
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
@@ -24,14 +36,46 @@ namespace Api
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.Authority = builder.Configuration["Authentication:PocketId:Authority"];
|
||||
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters()
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidAudiences = builder.Configuration["Authentication:PocketId:ClientId"]?.Split(';').Select(i => i.Trim()).ToArray(),
|
||||
ValidIssuers = builder.Configuration["Authentication:PocketId:Authority"]?.Split(';').Select(i => i.Trim()).ToArray()
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = jwtSettings["Issuer"],
|
||||
ValidateAudience = true,
|
||||
ValidAudience = jwtSettings["Audience"],
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnAuthenticationFailed = context =>
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("JwtAuthentication");
|
||||
logger.LogWarning("JWT authentication failed: {Exception}", context.Exception.Message);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnTokenValidated = context =>
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("JwtAuthentication");
|
||||
var userId = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
logger.LogInformation("JWT token validated for user {UserId}", userId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Add authorization
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// Register custom services
|
||||
builder.Services.AddHttpClient<IOAuthValidationService, OAuthValidationService>();
|
||||
builder.Services.AddScoped<IJwtService, JwtService>();
|
||||
builder.Services.AddScoped<IOAuthValidationService, OAuthValidationService>();
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("Default", policy =>
|
||||
@@ -43,27 +87,52 @@ namespace Api
|
||||
policy
|
||||
.WithOrigins(allowedHostsConfiguration ?? new[] { "*" })
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials(); // Allow credentials for JWT tokens
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// Add DbContext with SQL Server
|
||||
// Allow connection string to be set via environment variable (e.g., in Docker)
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||
{
|
||||
Name = "Authorization",
|
||||
Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http,
|
||||
Scheme = "Bearer",
|
||||
BearerFormat = "JWT",
|
||||
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
|
||||
Description = "JWT Authorization header using the Bearer scheme."
|
||||
});
|
||||
|
||||
options.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new Microsoft.OpenApi.Models.OpenApiReference
|
||||
{
|
||||
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
|
||||
Id = "Bearer"
|
||||
}
|
||||
},
|
||||
Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
139
Api/Services/JwtService.cs
Normal file
139
Api/Services/JwtService.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using Api.Models;
|
||||
using Api.Models.DTOs;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace Api.Services
|
||||
{
|
||||
public interface IJwtService
|
||||
{
|
||||
string GenerateToken(User user);
|
||||
ClaimsPrincipal? ValidateToken(string token);
|
||||
DateTime GetTokenExpiration(string token);
|
||||
}
|
||||
|
||||
public class JwtService : IJwtService
|
||||
{
|
||||
private readonly JwtSettings _jwtSettings;
|
||||
private readonly ILogger<JwtService> _logger;
|
||||
private readonly SymmetricSecurityKey _key;
|
||||
|
||||
public JwtService(IConfiguration configuration, ILogger<JwtService> logger)
|
||||
{
|
||||
_jwtSettings = configuration.GetSection("Jwt").Get<JwtSettings>()
|
||||
?? throw new InvalidOperationException("JWT settings not configured");
|
||||
_logger = logger;
|
||||
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
|
||||
}
|
||||
|
||||
public string GenerateToken(User user)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
new Claim("jti", Guid.NewGuid().ToString()),
|
||||
new Claim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
|
||||
};
|
||||
|
||||
// Add optional claims if available
|
||||
if (!string.IsNullOrEmpty(user.FirstName))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.GivenName, user.FirstName));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(user.LastName))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Surname, user.LastName));
|
||||
}
|
||||
|
||||
// Add provider information
|
||||
var providers = user.OAuthProviders.Select(p => p.Provider.ToString()).ToList();
|
||||
if (providers.Any())
|
||||
{
|
||||
claims.Add(new Claim("providers", string.Join(",", providers)));
|
||||
}
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationMinutes),
|
||||
SigningCredentials = credentials,
|
||||
Issuer = _jwtSettings.Issuer,
|
||||
Audience = _jwtSettings.Audience
|
||||
};
|
||||
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating JWT token for user {UserId}", user.Id);
|
||||
throw new InvalidOperationException("Failed to generate access token", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public ClaimsPrincipal? ValidateToken(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = _key,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = _jwtSettings.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = _jwtSettings.Audience,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew
|
||||
};
|
||||
|
||||
var principal = tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
|
||||
|
||||
// Ensure the token is a JWT token with the correct algorithm
|
||||
if (validatedToken is not JwtSecurityToken jwtToken ||
|
||||
!jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return principal;
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid token validation attempt");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error validating JWT token");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime GetTokenExpiration(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var jsonToken = tokenHandler.ReadJwtToken(token);
|
||||
return jsonToken.ValidTo;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error reading token expiration");
|
||||
throw new InvalidOperationException("Invalid token format", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
507
Api/Services/OAuthValidationService.cs
Normal file
507
Api/Services/OAuthValidationService.cs
Normal file
@@ -0,0 +1,507 @@
|
||||
using Api.Models;
|
||||
using Api.Models.DTOs;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Api.Services
|
||||
{
|
||||
public interface IOAuthValidationService
|
||||
{
|
||||
Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateIdTokenAsync(string idToken, string provider);
|
||||
(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractUserInfo(ClaimsPrincipal principal, string provider);
|
||||
Task<(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId)> ExtractUserInfoAsync(ClaimsPrincipal principal, string provider, string? idToken = null, string? accessToken = null);
|
||||
}
|
||||
|
||||
public class OAuthValidationService : IOAuthValidationService
|
||||
{
|
||||
private readonly OAuthProviderSettings _providerSettings;
|
||||
private readonly ILogger<OAuthValidationService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JwtSecurityTokenHandler _tokenHandler;
|
||||
|
||||
public OAuthValidationService(
|
||||
IConfiguration configuration,
|
||||
ILogger<OAuthValidationService> logger,
|
||||
HttpClient httpClient)
|
||||
{
|
||||
_providerSettings = configuration.GetSection("OAuth").Get<OAuthProviderSettings>()
|
||||
?? throw new InvalidOperationException("OAuth provider settings not configured");
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_tokenHandler = new JwtSecurityTokenHandler();
|
||||
}
|
||||
|
||||
public async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateIdTokenAsync(string idToken, string provider)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_providerSettings.Providers.TryGetValue(provider.ToLowerInvariant(), out var config))
|
||||
{
|
||||
return (false, null, $"Unsupported OAuth provider: {provider}");
|
||||
}
|
||||
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"microsoft" => await ValidateMicrosoftTokenAsync(idToken, config),
|
||||
"google" => await ValidateGoogleTokenAsync(idToken, config),
|
||||
"pocketid" => await ValidatePocketIdTokenAsync(idToken, config),
|
||||
_ => (false, null, $"Provider {provider} validation not implemented")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error validating {Provider} ID token", provider);
|
||||
return (false, null, "Token validation failed due to internal error");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateMicrosoftTokenAsync(string idToken, ProviderConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
// For Microsoft, we need to validate against their OIDC discovery document
|
||||
var discoveryUrl = $"{config.Authority}/.well-known/openid-configuration";
|
||||
var discovery = await GetDiscoveryDocumentAsync(discoveryUrl);
|
||||
|
||||
if (discovery == null)
|
||||
{
|
||||
return (false, null, "Failed to retrieve Microsoft discovery document");
|
||||
}
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = await GetSigningKeysAsync(discovery.JwksUri),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = discovery.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = config.ValidAudiences,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var principal = _tokenHandler.ValidateToken(idToken, validationParameters, out var validatedToken);
|
||||
return (true, principal, null);
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Microsoft token validation failed");
|
||||
return (false, null, "Invalid Microsoft ID token");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateGoogleTokenAsync(string idToken, ProviderConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
// For Google, use their discovery document
|
||||
var discoveryUrl = "https://accounts.google.com/.well-known/openid-configuration";
|
||||
var discovery = await GetDiscoveryDocumentAsync(discoveryUrl);
|
||||
|
||||
if (discovery == null)
|
||||
{
|
||||
return (false, null, "Failed to retrieve Google discovery document");
|
||||
}
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = await GetSigningKeysAsync(discovery.JwksUri),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { "https://accounts.google.com", "accounts.google.com" },
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = config.ValidAudiences,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var principal = _tokenHandler.ValidateToken(idToken, validationParameters, out var validatedToken);
|
||||
return (true, principal, null);
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Google token validation failed");
|
||||
return (false, null, "Invalid Google ID token");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidatePocketIdTokenAsync(string idToken, ProviderConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var discoveryUrl = $"{config.Authority}/.well-known/openid-configuration";
|
||||
var discovery = await GetDiscoveryDocumentAsync(discoveryUrl);
|
||||
|
||||
if (discovery == null)
|
||||
{
|
||||
return (false, null, "Failed to retrieve PocketId discovery document");
|
||||
}
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = await GetSigningKeysAsync(discovery.JwksUri),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = discovery.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = config.ValidAudiences,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var principal = _tokenHandler.ValidateToken(idToken, validationParameters, out var validatedToken);
|
||||
return (true, principal, null);
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PocketId token validation failed");
|
||||
return (false, null, "Invalid PocketId token");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DiscoveryDocument?> GetDiscoveryDocumentAsync(string discoveryUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetStringAsync(discoveryUrl);
|
||||
return JsonSerializer.Deserialize<DiscoveryDocument>(response, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve discovery document from {DiscoveryUrl}", discoveryUrl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<SecurityKey>> GetSigningKeysAsync(string jwksUri)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetStringAsync(jwksUri);
|
||||
var jwks = new JsonWebKeySet(response);
|
||||
return jwks.Keys;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve signing keys from {JwksUri}", jwksUri);
|
||||
return Enumerable.Empty<SecurityKey>();
|
||||
}
|
||||
}
|
||||
|
||||
public (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractUserInfo(ClaimsPrincipal principal, string provider)
|
||||
{
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"microsoft" => ExtractMicrosoftUserInfo(principal),
|
||||
"google" => ExtractGoogleUserInfo(principal),
|
||||
"pocketid" => ExtractPocketIdUserInfo(principal),
|
||||
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId)> ExtractUserInfoAsync(ClaimsPrincipal principal, string provider, string? idToken = null, string? accessToken = null)
|
||||
{
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"microsoft" => await ExtractMicrosoftUserInfoAsync(principal, idToken, accessToken),
|
||||
"google" => ExtractGoogleUserInfo(principal),
|
||||
"pocketid" => ExtractPocketIdUserInfo(principal),
|
||||
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId)> ExtractMicrosoftUserInfoAsync(ClaimsPrincipal principal, string? idToken = null, string? accessToken = null)
|
||||
{
|
||||
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
||||
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
||||
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
||||
var providerId = principal.FindFirst("sub")?.Value ?? principal.FindFirst("oid")?.Value ?? "";
|
||||
var profilePicture = principal.FindFirst("picture")?.Value;
|
||||
|
||||
// Log available claims for debugging
|
||||
_logger.LogInformation("Microsoft token claims: {Claims}",
|
||||
string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}")));
|
||||
|
||||
// Try to get additional info from Microsoft Graph using access token
|
||||
if ((string.IsNullOrEmpty(firstName) || string.IsNullOrEmpty(lastName) || string.IsNullOrEmpty(profilePicture)))
|
||||
{
|
||||
string? tokenToUse = null;
|
||||
|
||||
// Prefer access token over ID token for Microsoft Graph API calls
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
tokenToUse = accessToken;
|
||||
_logger.LogInformation("Using access token for Microsoft Graph API calls");
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(idToken))
|
||||
{
|
||||
tokenToUse = idToken;
|
||||
_logger.LogWarning("Using ID token for Microsoft Graph API calls (access token preferred)");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(tokenToUse))
|
||||
{
|
||||
try
|
||||
{
|
||||
var graphProfile = await GetMicrosoftGraphProfileAsync(tokenToUse);
|
||||
if (graphProfile != null)
|
||||
{
|
||||
firstName ??= graphProfile.GivenName;
|
||||
lastName ??= graphProfile.Surname;
|
||||
email = string.IsNullOrEmpty(email) ? (graphProfile.Mail ?? graphProfile.UserPrincipalName ?? email) : email;
|
||||
|
||||
// Try to get profile picture
|
||||
if (string.IsNullOrEmpty(profilePicture))
|
||||
{
|
||||
profilePicture = await GetMicrosoftGraphProfilePictureUrlAsync(tokenToUse);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get additional user info from Microsoft Graph using {TokenType}",
|
||||
!string.IsNullOrEmpty(accessToken) ? "access token" : "ID token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have name information from the token, try to extract from other claims
|
||||
if (string.IsNullOrEmpty(firstName) && string.IsNullOrEmpty(lastName))
|
||||
{
|
||||
// Try the 'name' claim first
|
||||
var name = principal.FindFirst("name")?.Value ?? principal.FindFirst(ClaimTypes.Name)?.Value;
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
var nameParts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (nameParts.Length >= 2)
|
||||
{
|
||||
firstName = nameParts[0];
|
||||
lastName = string.Join(" ", nameParts.Skip(1));
|
||||
}
|
||||
else if (nameParts.Length == 1)
|
||||
{
|
||||
firstName = nameParts[0];
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(email))
|
||||
{
|
||||
// Extract name from email as fallback
|
||||
var emailName = email.Split('@')[0];
|
||||
var emailParts = emailName.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (emailParts.Length >= 2)
|
||||
{
|
||||
firstName = FormatNameFromEmail(emailParts[0]);
|
||||
lastName = FormatNameFromEmail(emailParts[1]);
|
||||
}
|
||||
else if (emailParts.Length == 1)
|
||||
{
|
||||
firstName = FormatNameFromEmail(emailName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (email, firstName, lastName, profilePicture, providerId);
|
||||
}
|
||||
|
||||
private async Task<MicrosoftGraphProfile?> GetMicrosoftGraphProfileAsync(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try using the token as Authorization Bearer
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me");
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Microsoft Graph API call failed with status {StatusCode}: {ReasonPhrase}",
|
||||
response.StatusCode, response.ReasonPhrase);
|
||||
return null;
|
||||
}
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var profile = JsonSerializer.Deserialize<MicrosoftGraphProfile>(responseContent, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
_logger.LogInformation("Successfully retrieved Microsoft Graph profile: {DisplayName}", profile?.DisplayName);
|
||||
return profile;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get Microsoft Graph profile");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetMicrosoftGraphProfilePictureUrlAsync(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to get the photo directly - 404 is normal if user has no photo
|
||||
using var photoRequest = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me/photo/$value");
|
||||
photoRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var photoResponse = await _httpClient.SendAsync(photoRequest);
|
||||
|
||||
if (photoResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("User has no profile picture in Microsoft Graph (404 - normal behavior)");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!photoResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Microsoft Graph photo API call failed with status {StatusCode}: {ReasonPhrase}",
|
||||
photoResponse.StatusCode, photoResponse.ReasonPhrase);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the photo bytes and convert to base64 data URL
|
||||
var photoBytes = await photoResponse.Content.ReadAsByteArrayAsync();
|
||||
|
||||
// Check if we got valid image data
|
||||
if (photoBytes == null || photoBytes.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("Microsoft Graph returned empty photo data");
|
||||
return null;
|
||||
}
|
||||
|
||||
var contentType = photoResponse.Content.Headers.ContentType?.MediaType ?? "image/jpeg";
|
||||
var base64Photo = Convert.ToBase64String(photoBytes);
|
||||
var dataUrl = $"data:{contentType};base64,{base64Photo}";
|
||||
|
||||
_logger.LogInformation("Successfully retrieved Microsoft Graph profile picture as base64 data URL ({Size} bytes, type: {ContentType})",
|
||||
photoBytes.Length, contentType);
|
||||
|
||||
return dataUrl;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
|
||||
{
|
||||
_logger.LogDebug("User has no profile picture in Microsoft Graph (404)");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to get Microsoft Graph profile picture");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractMicrosoftUserInfo(ClaimsPrincipal principal)
|
||||
{
|
||||
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
||||
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
||||
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
||||
var providerId = principal.FindFirst("sub")?.Value ?? principal.FindFirst("oid")?.Value ?? "";
|
||||
var profilePicture = principal.FindFirst("picture")?.Value;
|
||||
|
||||
// Log available claims for debugging
|
||||
_logger.LogInformation("Microsoft token claims: {Claims}",
|
||||
string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}")));
|
||||
|
||||
// If we don't have name information from the token, try to extract from other claims
|
||||
if (string.IsNullOrEmpty(firstName) && string.IsNullOrEmpty(lastName))
|
||||
{
|
||||
// Try the 'name' claim first
|
||||
var name = principal.FindFirst("name")?.Value ?? principal.FindFirst(ClaimTypes.Name)?.Value;
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
var nameParts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (nameParts.Length >= 2)
|
||||
{
|
||||
firstName = nameParts[0];
|
||||
lastName = string.Join(" ", nameParts.Skip(1));
|
||||
}
|
||||
else if (nameParts.Length == 1)
|
||||
{
|
||||
firstName = nameParts[0];
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(email))
|
||||
{
|
||||
// Extract name from email as fallback
|
||||
var emailName = email.Split('@')[0];
|
||||
var emailParts = emailName.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (emailParts.Length >= 2)
|
||||
{
|
||||
firstName = FormatNameFromEmail(emailParts[0]);
|
||||
lastName = FormatNameFromEmail(emailParts[1]);
|
||||
}
|
||||
else if (emailParts.Length == 1)
|
||||
{
|
||||
firstName = FormatNameFromEmail(emailName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (email, firstName, lastName, profilePicture, providerId);
|
||||
}
|
||||
|
||||
private static string FormatNameFromEmail(string emailPart)
|
||||
{
|
||||
if (string.IsNullOrEmpty(emailPart)) return emailPart;
|
||||
|
||||
// Remove numbers and special characters, capitalize first letter
|
||||
var cleaned = new string(emailPart.Where(c => char.IsLetter(c)).ToArray());
|
||||
if (string.IsNullOrEmpty(cleaned)) return emailPart;
|
||||
|
||||
return char.ToUpper(cleaned[0]) + cleaned[1..].ToLower();
|
||||
}
|
||||
|
||||
private (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractGoogleUserInfo(ClaimsPrincipal principal)
|
||||
{
|
||||
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
||||
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
||||
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
||||
var providerId = principal.FindFirst("sub")?.Value ?? "";
|
||||
var profilePicture = principal.FindFirst("picture")?.Value;
|
||||
|
||||
return (email, firstName, lastName, profilePicture, providerId);
|
||||
}
|
||||
|
||||
private (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractPocketIdUserInfo(ClaimsPrincipal principal)
|
||||
{
|
||||
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
||||
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
||||
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
||||
var providerId = principal.FindFirst("sub")?.Value ?? "";
|
||||
var profilePicture = principal.FindFirst("picture")?.Value;
|
||||
|
||||
return (email, firstName, lastName, profilePicture, providerId);
|
||||
}
|
||||
}
|
||||
|
||||
public class DiscoveryDocument
|
||||
{
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
public string JwksUri { get; set; } = string.Empty;
|
||||
public string AuthorizationEndpoint { get; set; } = string.Empty;
|
||||
public string TokenEndpoint { get; set; } = string.Empty;
|
||||
public string UserinfoEndpoint { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class MicrosoftGraphProfile
|
||||
{
|
||||
public string? GivenName { get; set; }
|
||||
public string? Surname { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Mail { get; set; }
|
||||
public string? UserPrincipalName { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public string? JobTitle { get; set; }
|
||||
public string? MobilePhone { get; set; }
|
||||
public string? OfficeLocation { get; set; }
|
||||
public string? PreferredLanguage { get; set; }
|
||||
// Note: Microsoft Graph doesn't provide direct profile picture URLs in the /me endpoint
|
||||
// Profile pictures must be retrieved separately via /me/photo/$value
|
||||
}
|
||||
}
|
||||
70
Api/User.http
Normal file
70
Api/User.http
Normal file
@@ -0,0 +1,70 @@
|
||||
### User Controller API Tests
|
||||
|
||||
@baseUrl = http://localhost:5000
|
||||
@authToken = YOUR_JWT_TOKEN_HERE
|
||||
|
||||
### Get all users with pagination
|
||||
GET {{baseUrl}}/api/user?page=1&pageSize=10
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Get all users with search
|
||||
GET {{baseUrl}}/api/user?search=john&isActive=true
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Get specific user by ID
|
||||
GET {{baseUrl}}/api/user/1
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Get current user profile
|
||||
GET {{baseUrl}}/api/user/me
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Create a new user
|
||||
POST {{baseUrl}}/api/user
|
||||
Authorization: Bearer {{authToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "newuser@example.com",
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"profilePictureUrl": "https://example.com/profile.jpg",
|
||||
"isActive": true
|
||||
}
|
||||
|
||||
### Update a user
|
||||
PUT {{baseUrl}}/api/user/1
|
||||
Authorization: Bearer {{authToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"firstName": "Updated John",
|
||||
"lastName": "Updated Doe",
|
||||
"isActive": true
|
||||
}
|
||||
|
||||
### Update current user profile
|
||||
PUT {{baseUrl}}/api/user/me
|
||||
Authorization: Bearer {{authToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"firstName": "My Updated Name",
|
||||
"profilePictureUrl": "https://example.com/new-profile.jpg"
|
||||
}
|
||||
|
||||
### Soft delete user (deactivate)
|
||||
DELETE {{baseUrl}}/api/user/1
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Reactivate a soft-deleted user
|
||||
POST {{baseUrl}}/api/user/1/reactivate
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Permanently delete user
|
||||
DELETE {{baseUrl}}/api/user/1/permanent
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Get user statistics
|
||||
GET {{baseUrl}}/api/user/statistics
|
||||
Authorization: Bearer {{authToken}}
|
||||
@@ -1,4 +1,29 @@
|
||||
{
|
||||
"Jwt": {
|
||||
"SecretKey": "your-very-secure-secret-key-that-should-be-at-least-32-characters-long",
|
||||
"Issuer": "https://api.yourapp.com",
|
||||
"Audience": "https://yourapp.com",
|
||||
"ExpirationMinutes": 60
|
||||
},
|
||||
"OAuth": {
|
||||
"Providers": {
|
||||
"microsoft": {
|
||||
"Authority": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
|
||||
"ClientId": "904abfa3-d392-400b-8de8-284e6041e929",
|
||||
"ValidAudiences": ["904abfa3-d392-400b-8de8-284e6041e929"]
|
||||
},
|
||||
"google": {
|
||||
"Authority": "https://accounts.google.com",
|
||||
"ClientId": "1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com",
|
||||
"ValidAudiences": ["1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com"]
|
||||
},
|
||||
"pocketid": {
|
||||
"Authority": "https://identity.lesko.me",
|
||||
"ClientId": "21131567-fea1-42a2-8907-21abd874eff8",
|
||||
"ValidAudiences": ["21131567-fea1-42a2-8907-21abd874eff8"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Authentication": {
|
||||
"PocketId": {
|
||||
"Authority": "https://identity.lesko.me",
|
||||
@@ -16,5 +41,4 @@
|
||||
},
|
||||
"AllowedHosts": "localhost",
|
||||
"CorsOrigins": "https://localhost:5001,http://localhost:4200,http://localhost:5000"
|
||||
|
||||
}
|
||||
@@ -1,3 +1,18 @@
|
||||
{
|
||||
"apiEndpoint": "http://localhost:5000"
|
||||
"apiEndpoint": "http://localhost:5000",
|
||||
"oauthProviders": {
|
||||
"microsoft": {
|
||||
"clientId": "904abfa3-d392-400b-8de8-284e6041e929",
|
||||
"issuer": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0"
|
||||
},
|
||||
"google": {
|
||||
"clientId": "1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com",
|
||||
"issuer": "https://accounts.google.com",
|
||||
"dummyClientSecret": "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"
|
||||
},
|
||||
"pocketid": {
|
||||
"clientId": "21131567-fea1-42a2-8907-21abd874eff8",
|
||||
"issuer": "https://identity.lesko.me"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
Web/public/images/svgs/microsoft-icon.svg
Normal file
6
Web/public/images/svgs/microsoft-icon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="11" height="11" fill="#F25022"/>
|
||||
<rect x="15" y="2" width="11" height="11" fill="#7FBA00"/>
|
||||
<rect x="2" y="15" width="11" height="11" fill="#00A4EF"/>
|
||||
<rect x="15" y="15" width="11" height="11" fill="#FFB900"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
@@ -41,9 +41,10 @@ import { adapterFactory } from 'angular-calendar/date-adapters/date-fns';
|
||||
// code view
|
||||
import { provideHighlightOptions } from 'ngx-highlightjs';
|
||||
import 'highlight.js/styles/atom-one-dark.min.css';
|
||||
import { DefaultOAuthInterceptor, OAuthModule, provideOAuthClient } from 'angular-oauth2-oidc';
|
||||
import { OAuthModule, provideOAuthClient } from 'angular-oauth2-oidc';
|
||||
import { AppConfigService } from './services/config.service';
|
||||
import { ApiEndpointInterceptor } from './services/http.interceptor';
|
||||
import { AuthInterceptor } from './services/auth.interceptor';
|
||||
|
||||
export function HttpLoaderFactory(http: HttpClient): any {
|
||||
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
|
||||
@@ -74,7 +75,7 @@ export const appConfig: ApplicationConfig = {
|
||||
provideAppInitializer(() => inject(AppConfigService).loadConfig()),
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: DefaultOAuthInterceptor,
|
||||
useClass: AuthInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
@@ -84,12 +85,7 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideOAuthClient({
|
||||
resourceServer: {
|
||||
allowedUrls: ['http://localhost:5000', 'https://localhost:4200', 'https://centrum.lesko.me', 'https://beta.e-dias.sk/'],
|
||||
sendAccessToken: true,
|
||||
},
|
||||
}),
|
||||
provideOAuthClient(),
|
||||
|
||||
// provideClientHydration(),
|
||||
provideAnimationsAsync(),
|
||||
|
||||
@@ -3,15 +3,23 @@ import { inject } from '@angular/core';
|
||||
import { AuthenticationService } from './services/authentication.service';
|
||||
|
||||
|
||||
export const authGuard: CanActivateFn = (route, state) => {
|
||||
export const authGuard: CanActivateFn = async (route, state) => {
|
||||
const auth = inject(AuthenticationService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (auth.hasValidAccessToken())
|
||||
if (auth.profile)
|
||||
if (auth.hasValidAccessToken()) {
|
||||
if (auth.profile) {
|
||||
return true;
|
||||
else
|
||||
return auth.handleCallback();
|
||||
} else {
|
||||
try {
|
||||
const user = await auth.handleCallback();
|
||||
return user ? true : router.parseUrl('/authentication/login');
|
||||
} catch (error) {
|
||||
console.error('Auth guard callback error:', error);
|
||||
return router.parseUrl('/authentication/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// redirect to the login page (UrlTree) so navigation does not fail silently
|
||||
return router.parseUrl('/authentication/login');
|
||||
|
||||
35
Web/src/app/models/auth.models.ts
Normal file
35
Web/src/app/models/auth.models.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface AuthenticateRequest {
|
||||
idToken: string;
|
||||
provider: string;
|
||||
accessToken?: string; // Optional access token for API calls
|
||||
}
|
||||
|
||||
export interface AuthenticateResponse {
|
||||
accessToken: string;
|
||||
expiresAt: string;
|
||||
user: UserProfile;
|
||||
isNewUser: boolean;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
profilePictureUrl?: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
providers: string[];
|
||||
|
||||
// Computed properties for template compatibility
|
||||
name?: string; // Will be computed from firstName + lastName
|
||||
picture?: string; // Alias for profilePictureUrl
|
||||
role?: string; // Default user role
|
||||
}
|
||||
|
||||
export interface OAuthConfig {
|
||||
provider: 'Microsoft' | 'Google' | 'PocketId';
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
scope?: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<div class="blank-layout-container justify-content-center">
|
||||
<div class="position-relative row w-100 h-100">
|
||||
<div class="col-12 d-flex align-items-center justify-content-center h-100">
|
||||
<div class="text-center">
|
||||
<!-- Loading State -->
|
||||
<div *ngIf="loading && !error" class="d-flex flex-column align-items-center">
|
||||
<mat-spinner diameter="48" class="m-b-16"></mat-spinner>
|
||||
<h4 class="f-w-500 f-s-20 m-b-8">Spracúvame prihlásenie...</h4>
|
||||
<p class="f-s-14 text-muted">Prosím počkajte, overujeme vaše údaje.</p>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div *ngIf="!loading && !error && profile" class="d-flex flex-column align-items-center">
|
||||
<mat-icon class="text-success f-s-48 m-b-16">check_circle</mat-icon>
|
||||
<h4 class="f-w-500 f-s-20 m-b-8">Prihlásenie úspešné!</h4>
|
||||
<p class="f-s-14 text-muted m-b-16">Vitajte, {{ profile.firstName || profile.email }}!</p>
|
||||
<p class="f-s-12 text-muted">Presmerovávame vás na hlavnú stránku...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div *ngIf="error" class="d-flex flex-column align-items-center">
|
||||
<mat-icon class="text-danger f-s-48 m-b-16">error</mat-icon>
|
||||
<h4 class="f-w-500 f-s-20 m-b-8">Prihlásenie zlyhalo</h4>
|
||||
<p class="f-s-14 text-muted m-b-16">{{ error }}</p>
|
||||
<p class="f-s-12 text-muted">Presmerovávame vás späť na prihlásenie...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,31 +1,52 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { CoreService } from '../../../services/core.service';
|
||||
import { MaterialModule } from '../../../material.module';
|
||||
import { AuthenticationService } from '../../../services/authentication.service';
|
||||
import { NgScrollbarModule } from "ngx-scrollbar";
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-callback',
|
||||
imports: [RouterModule, MaterialModule, NgScrollbarModule],
|
||||
imports: [RouterModule, MaterialModule, NgScrollbarModule, CommonModule],
|
||||
templateUrl: './callback.component.html',
|
||||
})
|
||||
export class CallbackComponent {
|
||||
export class CallbackComponent implements OnInit {
|
||||
options: any;
|
||||
profile: any;
|
||||
loading = true;
|
||||
error: string | null = null;
|
||||
|
||||
constructor(private settings: CoreService, private as: AuthenticationService, private router: Router) {
|
||||
this.options = this.settings.getOptions();
|
||||
}
|
||||
|
||||
// Handle the OAuth2 callback and load user profile
|
||||
this.as
|
||||
.handleCallback()
|
||||
.then(_ => {
|
||||
console.log('Login successful');
|
||||
this.profile = this.as.profile;
|
||||
this.router.navigate(['/dashboard/main']);
|
||||
}).catch(err => {
|
||||
console.error('Error handling callback', err);
|
||||
});
|
||||
async ngOnInit() {
|
||||
try {
|
||||
// Handle the OAuth2 callback and authenticate with our API
|
||||
const user = await this.as.handleCallback();
|
||||
|
||||
if (user) {
|
||||
console.log('Login successful', user);
|
||||
this.profile = user;
|
||||
this.loading = false;
|
||||
|
||||
// Redirect to dashboard after a short delay
|
||||
setTimeout(() => {
|
||||
this.router.navigate(['/dashboard/main']);
|
||||
}, 1500);
|
||||
} else {
|
||||
throw new Error('Authentication failed - no user returned');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error handling callback', err);
|
||||
this.error = err.message || 'Authentication failed. Please try again.';
|
||||
this.loading = false;
|
||||
|
||||
// Redirect to login after error delay
|
||||
setTimeout(() => {
|
||||
this.router.navigate(['/authentication/side-login']);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,28 @@
|
||||
<span class="f-s-14 d-block f-s-14 m-t-8">Váš uživateľský prístup</span>
|
||||
|
||||
<div class="row m-t-24 align-items-center">
|
||||
<a mat-stroked-button class="w-100" (click)="googleLogin()">
|
||||
<a mat-stroked-button class="w-100" (click)="loginWithMicrosoft()" [disabled]="loading">
|
||||
<div class="d-flex align-items-center">
|
||||
<img src="/assets/images/svgs/google-icon.svg" alt="google" width="16" class="m-r-8" />
|
||||
Prihlásiť sa pomocou Google
|
||||
<img src="/assets/images/svgs/microsoft-icon.svg" alt="microsoft" width="16" class="m-r-8" />
|
||||
<span *ngIf="!loading">Prihlásiť sa pomocou Microsoft</span>
|
||||
<span *ngIf="loading">Prihlasovanie...</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a mat-stroked-button class="w-100" style="margin-top:12px" (click)="pocketLogin()">
|
||||
<a mat-stroked-button class="w-100" style="margin-top:12px" (click)="loginWithGoogle()" [disabled]="loading">
|
||||
<div class="d-flex align-items-center">
|
||||
<img src="/assets/images/svgs/google-icon.svg" alt="google" width="16" class="m-r-8" />
|
||||
<span *ngIf="!loading">Prihlásiť sa pomocou Google</span>
|
||||
<span *ngIf="loading">Prihlasovanie...</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a mat-stroked-button class="w-100" style="margin-top:12px" (click)="loginWithPocketId()" [disabled]="loading">
|
||||
<div class="d-flex align-items-center">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pocket-id-light.svg"
|
||||
alt="google" width="16" class="m-r-8" />
|
||||
Prihlásiť sa pomocou PocketId
|
||||
alt="pocketid" width="16" class="m-r-8" />
|
||||
<span *ngIf="!loading">Prihlásiť sa pomocou PocketId</span>
|
||||
<span *ngIf="loading">Prihlasovanie...</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -5,31 +5,54 @@ import { Router, RouterModule } from '@angular/router';
|
||||
import { MaterialModule } from '../../../material.module';
|
||||
import { BrandingComponent } from '../../../layouts/full/vertical/sidebar/branding.component';
|
||||
import { AuthenticationService } from '../../../services/authentication.service';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-login',
|
||||
imports: [RouterModule, MaterialModule, FormsModule, ReactiveFormsModule, BrandingComponent],
|
||||
imports: [RouterModule, MaterialModule, FormsModule, ReactiveFormsModule, BrandingComponent, CommonModule],
|
||||
templateUrl: './side-login.component.html'
|
||||
})
|
||||
export class AppSideLoginComponent {
|
||||
options: any;
|
||||
loading = false;
|
||||
|
||||
constructor(private settings: CoreService, private router: Router, private readonly as: AuthenticationService) {
|
||||
this.options = this.settings.getOptions();
|
||||
}
|
||||
|
||||
googleLogin() {
|
||||
this.as.configureAndLogin({
|
||||
issuer: 'https://accounts.google.com',
|
||||
clientId: '1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com',
|
||||
dummyClientSecret: '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',
|
||||
});
|
||||
async loginWithMicrosoft() {
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
await this.as.loginWithMicrosoft();
|
||||
} catch (error) {
|
||||
console.error('Microsoft login failed:', error);
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
pocketLogin() {
|
||||
this.as.configureAndLogin({
|
||||
issuer: 'https://identity.lesko.me',
|
||||
clientId: '21131567-fea1-42a2-8907-21abd874eff8',
|
||||
});
|
||||
async loginWithGoogle() {
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
await this.as.loginWithGoogle();
|
||||
} catch (error) {
|
||||
console.error('Google login failed:', error);
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loginWithPocketId() {
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
await this.as.loginWithPocketId();
|
||||
} catch (error) {
|
||||
console.error('PocketId login failed:', error);
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,90 +11,98 @@
|
||||
</div>
|
||||
|
||||
<div class="form-content">
|
||||
<form>
|
||||
<div class="container">
|
||||
<!-- Map stays inside, but will visually float out -->
|
||||
<div class="map-container overflow-hidden rounded">
|
||||
<iframe class="overflow-hidden rounded"
|
||||
src="https://maps.google.com/maps?q=48.72388977142655,21.247448657208842&z=15&output=embed"
|
||||
width="100%" height="400" style="border: 0" allowfullscreen="" loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"></iframe>
|
||||
</div>
|
||||
<div class="spacing-top-bottom">
|
||||
<div class="row">
|
||||
<div class="col-md-8 m-b-30">
|
||||
<div class="row ">
|
||||
<div class="col-md-6">
|
||||
<!-- input -->
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Meno<span>*</span></mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100" color="primary">
|
||||
<input matInput type="text" placeholder="Meno" />
|
||||
</mat-form-field>
|
||||
<!-- input -->
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Telefónne číslo<span>*</span></mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100" color="primary">
|
||||
<input matInput type="tel" placeholder="xxxx xxx xxxx" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<!-- input -->
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Priezvisko<span>*</span></mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100" color="primary">
|
||||
<input matInput type="text" placeholder="Priezvisko" />
|
||||
</mat-form-field>
|
||||
<!-- input -->
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Email<span>*</span></mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100" color="primary">
|
||||
<input matInput type="mail" placeholder="Emailová adresa" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="container">
|
||||
<!-- Map stays inside, but will visually float out -->
|
||||
<div class="map-container overflow-hidden rounded">
|
||||
<iframe class="overflow-hidden rounded"
|
||||
src="https://maps.google.com/maps?q=48.72388977142655,21.247448657208842&z=15&output=embed" width="100%"
|
||||
height="400" style="border: 0" allowfullscreen="" loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"></iframe>
|
||||
</div>
|
||||
<div class="spacing-top-bottom">
|
||||
<div class="row">
|
||||
<div class="col-md-8 m-b-30">
|
||||
<div class="row ">
|
||||
<div class="col-md-6">
|
||||
<!-- input -->
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Meno<span>*</span></mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100" color="primary">
|
||||
<input matInput type="text" placeholder="Meno" [(ngModel)]="model.name" />
|
||||
</mat-form-field>
|
||||
<!-- input -->
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Telefónne číslo<span>*</span></mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100" color="primary">
|
||||
<input matInput type="tel" placeholder="xxxx xxx xxxx" [(ngModel)]="model.phone" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<!-- input -->
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Priezvisko<span>*</span></mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100" color="primary">
|
||||
<input matInput type="text" placeholder="Priezvisko" [(ngModel)]="model.surname" />
|
||||
</mat-form-field>
|
||||
<!-- input -->
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Email<span>*</span></mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100" color="primary">
|
||||
<input matInput type="mail" placeholder="Emailová adresa" [(ngModel)]="model.email" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Dopyt týkajúci sa<span>*</span>
|
||||
</mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-select value="General Enquiry">
|
||||
<mat-option value="General Enquiry">Všeobecný dopyt</mat-option>
|
||||
<mat-option value="Registration Enquiry">Žiadosť o registráciu</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Dopyt týkajúci sa<span>*</span>
|
||||
</mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-select value="General Enquiry" [(ngModel)]="model.subject">
|
||||
<mat-option value="General Enquiry">Všeobecný dopyt</mat-option>
|
||||
<mat-option value="Registration Enquiry">Žiadosť o registráciu</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Správa</mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100" color="primary">
|
||||
<textarea matInput rows="5" placeholder="Napíšte svoju správu sem"></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<mat-label class="f-s-14 f-w-600 m-b-8 d-block">Správa</mat-label>
|
||||
<mat-form-field appearance="outline" class="w-100" color="primary">
|
||||
<textarea matInput rows="5" placeholder="Napíšte svoju správu sem" [(ngModel)]="model.message"
|
||||
name="message"></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<button mat-flat-button>Odoslať</button>
|
||||
</div>
|
||||
<div class="col-md-4 right-side-content">
|
||||
<mat-card class="shadow-none bg-primary rounded-8 p-8">
|
||||
<mat-card-content>
|
||||
<div>
|
||||
<h6 class="f-s-20 m-b-16 text-white">Kontaktujte nás dnes</h6>
|
||||
<p class="f-s-14 m-0 text-white">
|
||||
Máte otázky alebo potrebujete pomoc? Napíšte nám správu.
|
||||
</p>
|
||||
</div>
|
||||
<mat-divider class="m-y-30"></mat-divider>
|
||||
<div>
|
||||
<h6 class="f-s-20 m-b-16 text-white">Naša lokalita</h6>
|
||||
<p class="f-s-14 m-0 text-white">
|
||||
Navštívte nás osobne alebo nájdite naše kontaktné údaje, aby ste sa s nami mohli priamo spojiť.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- reCAPTCHA widget: uses siteKey from component -->
|
||||
<div class="row m-t-16">
|
||||
<div class="col-lg-12">
|
||||
<!-- anchor for programmatic render -->
|
||||
<div #recaptcha class="g-recaptcha"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button mat-flat-button type="button" (click)="onSubmit()">Odoslať</button>
|
||||
</div>
|
||||
<div class="col-md-4 right-side-content">
|
||||
<mat-card class="shadow-none bg-primary rounded-8 p-8">
|
||||
<mat-card-content>
|
||||
<div>
|
||||
<h6 class="f-s-20 m-b-16 text-white">Kontaktujte nás dnes</h6>
|
||||
<p class="f-s-14 m-0 text-white">
|
||||
Máte otázky alebo potrebujete pomoc? Napíšte nám správu.
|
||||
</p>
|
||||
</div>
|
||||
<mat-divider class="m-y-30"></mat-divider>
|
||||
<div>
|
||||
<h6 class="f-s-20 m-b-16 text-white">Naša lokalita</h6>
|
||||
<p class="f-s-14 m-0 text-white">
|
||||
Navštívte nás osobne alebo nájdite naše kontaktné údaje, aby ste sa s nami mohli priamo spojiť.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<app-footer></app-footer>
|
||||
|
||||
@@ -1,14 +1,92 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, AfterViewInit, ViewChild, ElementRef, NgZone } from '@angular/core';
|
||||
import { IconModule } from '../../../icon/icon.module';
|
||||
import { MaterialModule } from '../../../material.module';
|
||||
import { FooterComponent } from '../footer/footer.component';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contact',
|
||||
imports: [MaterialModule,IconModule,FooterComponent],
|
||||
imports: [MaterialModule, IconModule, FooterComponent, FormsModule],
|
||||
templateUrl: './contact.component.html',
|
||||
styleUrl: './contact.component.scss'
|
||||
})
|
||||
export class ContactComponent {
|
||||
export class ContactComponent implements AfterViewInit {
|
||||
model: any = {
|
||||
name: '',
|
||||
surname: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: ''
|
||||
};
|
||||
siteKey: string = '6Lf6df0rAAAAAMXcAx1umneXl1QJo9rTrflpWCvB';
|
||||
@ViewChild('recaptcha', { static: false }) recaptchaElem!: ElementRef;
|
||||
widgetId: number | null = null;
|
||||
|
||||
constructor(private http: HttpClient, private toastr: ToastrService) { }
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const win: any = window as any;
|
||||
const renderWidget = () => {
|
||||
const grecaptcha = win.grecaptcha;
|
||||
if (grecaptcha && grecaptcha.render && this.recaptchaElem) {
|
||||
// render widget and keep id
|
||||
this.widgetId = grecaptcha.render(this.recaptchaElem.nativeElement, { sitekey: this.siteKey });
|
||||
}
|
||||
};
|
||||
|
||||
// if grecaptcha already loaded, render immediately
|
||||
if ((win as any).grecaptcha && (win as any).grecaptcha.render) {
|
||||
renderWidget();
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise define global callback that the index.html script will call when loaded
|
||||
(win as any).onRecaptchaLoad = () => {
|
||||
setTimeout(renderWidget);
|
||||
};
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
// get token from grecaptcha
|
||||
const grecaptchaAny: any = (window as any).grecaptcha;
|
||||
let token = '';
|
||||
if (grecaptchaAny && grecaptchaAny.getResponse) {
|
||||
token = this.widgetId !== null ? grecaptchaAny.getResponse(this.widgetId) : grecaptchaAny.getResponse();
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
// optionally show UI feedback to complete captcha
|
||||
this.toastr.warning('Prosím, potvrďte reCAPTCHA.', 'Upozornenie');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: this.model.name,
|
||||
surname: this.model.surname,
|
||||
phone: this.model.phone,
|
||||
email: this.model.email,
|
||||
subject: this.model.subject,
|
||||
message: this.model.message,
|
||||
recaptchatoken: token
|
||||
};
|
||||
|
||||
// POST to your backend endpoint which must verify the token with Google
|
||||
this.http.post('/api/webmessages', payload).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Správa odoslaná', 'Úspech');
|
||||
// reset widget
|
||||
if (grecaptchaAny && grecaptchaAny.reset) {
|
||||
if (this.widgetId !== null) grecaptchaAny.reset(this.widgetId);
|
||||
else grecaptchaAny.reset();
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(err);
|
||||
this.toastr.error('Chyba pri odosielaní — skúste znova.', 'Chyba');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
29
Web/src/app/services/auth.interceptor.ts
Normal file
29
Web/src/app/services/auth.interceptor.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthenticationService } from './authentication.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthInterceptor implements HttpInterceptor {
|
||||
constructor(private authService: AuthenticationService) { }
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
// Skip adding auth header for authentication endpoints
|
||||
if (req.url.includes('/api/auth/authenticate') || req.url.includes('/assets/')) {
|
||||
return next.handle(req);
|
||||
}
|
||||
|
||||
// Get custom access token
|
||||
const token = this.authService.getCustomAccessToken();
|
||||
|
||||
if (token) {
|
||||
// Clone request and add Authorization header
|
||||
const authReq = req.clone({
|
||||
headers: req.headers.set('Authorization', `Bearer ${token}`)
|
||||
});
|
||||
return next.handle(authReq);
|
||||
}
|
||||
|
||||
return next.handle(req);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthConfig, OAuthService } from 'angular-oauth2-oidc';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, Observable, firstValueFrom } from 'rxjs';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { AuthenticateRequest, AuthenticateResponse, UserProfile, OAuthConfig } from '../models/auth.models';
|
||||
import { AppConfigService } from './config.service';
|
||||
|
||||
const CONFIG_KEY = 'oauth_config_v1';
|
||||
const TOKEN_KEY = 'custom_access_token';
|
||||
const USER_KEY = 'user_profile';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthenticationService {
|
||||
|
||||
private config: Partial<AuthConfig> = {
|
||||
redirectUri: window.location.origin + '/authentication/callback',
|
||||
|
||||
redirectUri: this.getRedirectUri(),
|
||||
scope: 'openid profile email',
|
||||
responseType: 'code',
|
||||
requireHttps: false,
|
||||
@@ -16,15 +24,43 @@ export class AuthenticationService {
|
||||
timeoutFactor: 0.01,
|
||||
};
|
||||
|
||||
public profile: any = null;
|
||||
private getRedirectUri(): string {
|
||||
// Use the current origin + callback path
|
||||
const origin = window.location.origin;
|
||||
|
||||
constructor(private oauthService: OAuthService, private router: Router) { }
|
||||
// For development/testing environments, ensure we use the right callback
|
||||
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
||||
return `${origin}/authentication/callback`;
|
||||
}
|
||||
|
||||
// For Gitpod/Codespaces or other cloud IDEs
|
||||
if (origin.includes('gitpod.io') || origin.includes('github.dev') || origin.includes('codespaces')) {
|
||||
return `${origin}/authentication/callback`;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return `${origin}/authentication/callback`;
|
||||
}
|
||||
|
||||
public profile: UserProfile | null = null;
|
||||
private userSubject = new BehaviorSubject<UserProfile | null>(null);
|
||||
public user$ = this.userSubject.asObservable();
|
||||
|
||||
constructor(
|
||||
private oauthService: OAuthService,
|
||||
private router: Router,
|
||||
private http: HttpClient,
|
||||
private toastr: ToastrService,
|
||||
private configService: AppConfigService
|
||||
) {
|
||||
this.loadStoredUser();
|
||||
}
|
||||
|
||||
saveConfig(cfg: Partial<AuthConfig>) {
|
||||
try {
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
|
||||
} catch {
|
||||
// ignore}
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +73,57 @@ export class AuthenticationService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadStoredUser(): void {
|
||||
try {
|
||||
const userJson = localStorage.getItem(USER_KEY);
|
||||
if (userJson) {
|
||||
const user = JSON.parse(userJson) as UserProfile;
|
||||
// Ensure computed properties are present (in case they were stored without them)
|
||||
const enhancedUser: UserProfile = {
|
||||
...user,
|
||||
name: user.name || (user.firstName && user.lastName
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: user.firstName || user.lastName || user.email),
|
||||
picture: user.picture || user.profilePictureUrl || '/assets/images/profile/user-1.jpg',
|
||||
role: user.role || 'Používateľ'
|
||||
};
|
||||
this.profile = enhancedUser;
|
||||
this.userSubject.next(this.profile);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private saveUser(user: UserProfile): void {
|
||||
try {
|
||||
// Populate computed properties for template compatibility
|
||||
const enhancedUser: UserProfile = {
|
||||
...user,
|
||||
name: user.firstName && user.lastName
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: user.firstName || user.lastName || user.email,
|
||||
picture: user.profilePictureUrl || '/assets/images/profile/user-1.jpg', // Default avatar
|
||||
role: 'Používateľ' // Default role in Slovak
|
||||
};
|
||||
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(enhancedUser));
|
||||
this.profile = enhancedUser;
|
||||
this.userSubject.next(enhancedUser);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private saveCustomToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the library and persist configuration
|
||||
configure(cfg: Partial<AuthConfig>) {
|
||||
this.config = { ...this.config, ...cfg };
|
||||
@@ -61,6 +148,10 @@ export class AuthenticationService {
|
||||
// Start login flow using discovery document + Authorization Code (PKCE)
|
||||
startLogin(cfg?: Partial<AuthConfig>): Promise<void> {
|
||||
if (cfg) this.configure(cfg);
|
||||
|
||||
console.log('OAuth Config:', this.config);
|
||||
console.log('Redirect URI:', this.config.redirectUri);
|
||||
|
||||
return this.oauthService
|
||||
.loadDiscoveryDocument()
|
||||
.then(() => {
|
||||
@@ -70,40 +161,202 @@ export class AuthenticationService {
|
||||
}
|
||||
|
||||
// Call this on the callback route to process the redirect and obtain tokens + profile
|
||||
handleCallback(): Promise<any> {
|
||||
if (this.restoreConfiguration())
|
||||
// Ensure discovery document loaded, then process code flow, then load profile
|
||||
return this.oauthService
|
||||
.loadDiscoveryDocumentAndTryLogin()
|
||||
.then((isLoggedIn: boolean) => {
|
||||
if (!isLoggedIn && !this.oauthService.hasValidAccessToken()) {
|
||||
return Promise.reject('No valid token after callback');
|
||||
}
|
||||
return this.loadUserProfile();
|
||||
});
|
||||
else
|
||||
return this.router.navigate(['/authentication/login']);
|
||||
async handleCallback(): Promise<UserProfile | null> {
|
||||
if (!this.restoreConfiguration()) {
|
||||
this.router.navigate(['/authentication/login']);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process OAuth callback to get ID token
|
||||
const isLoggedIn = await this.oauthService.loadDiscoveryDocumentAndTryLogin();
|
||||
|
||||
if (!isLoggedIn && !this.oauthService.hasValidAccessToken()) {
|
||||
throw new Error('No valid token after callback');
|
||||
}
|
||||
|
||||
// Get the ID token from the OAuth service
|
||||
const idToken = this.oauthService.getIdToken();
|
||||
if (!idToken) {
|
||||
throw new Error('No ID token received from OAuth provider');
|
||||
}
|
||||
|
||||
// Get the access token for API calls (if available)
|
||||
const accessToken = this.oauthService.getAccessToken();
|
||||
|
||||
// Determine the provider based on the current OAuth configuration
|
||||
const provider = this.determineProvider();
|
||||
|
||||
// Call our API to authenticate and get custom access token
|
||||
const authResponse = await this.authenticateWithApi(idToken, provider, accessToken);
|
||||
|
||||
// Save the custom access token and user profile
|
||||
this.saveCustomToken(authResponse.accessToken);
|
||||
this.saveUser(authResponse.user);
|
||||
|
||||
// Show success message
|
||||
this.toastr.success(
|
||||
authResponse.isNewUser ? 'Account created successfully!' : 'Logged in successfully!',
|
||||
'Authentication'
|
||||
);
|
||||
|
||||
return authResponse.user;
|
||||
} catch (error) {
|
||||
console.error('Authentication callback failed:', error);
|
||||
this.toastr.error('Authentication failed. Please try again.', 'Error');
|
||||
this.router.navigate(['/authentication/login']);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private determineProvider(): string {
|
||||
const config = this.oauthService.issuer;
|
||||
if (config.includes('login.microsoftonline.com')) {
|
||||
return 'Microsoft';
|
||||
} else if (config.includes('accounts.google.com')) {
|
||||
return 'Google';
|
||||
} else if (config.includes('identity.lesko.me')) {
|
||||
return 'PocketId';
|
||||
}
|
||||
throw new Error(`Unknown OAuth provider: ${config}`);
|
||||
}
|
||||
|
||||
private async authenticateWithApi(idToken: string, provider: string, accessToken?: string): Promise<AuthenticateResponse> {
|
||||
const request: AuthenticateRequest = {
|
||||
idToken,
|
||||
provider,
|
||||
...(accessToken && { accessToken }) // Only include accessToken if it exists
|
||||
};
|
||||
|
||||
console.log('Authenticating with API:', {
|
||||
provider,
|
||||
hasIdToken: !!idToken,
|
||||
hasAccessToken: !!accessToken
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<AuthenticateResponse>('/api/auth/authenticate', request)
|
||||
);
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
console.error('API authentication failed:', error);
|
||||
if (error.error?.message) {
|
||||
throw new Error(error.error.message);
|
||||
}
|
||||
throw new Error('Authentication with API failed');
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<UserProfile | null> {
|
||||
if (!this.hasValidCustomToken()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<UserProfile>('/api/auth/me')
|
||||
);
|
||||
this.saveUser(response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to get current user:', error);
|
||||
this.clearAuth();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
loadUserProfile(): Promise<any> {
|
||||
// This method is kept for backward compatibility
|
||||
return this.oauthService.loadUserProfile()
|
||||
.then(profile => {
|
||||
this.profile = profile["info"];
|
||||
return new Promise((resolve) => resolve(profile["info"]));
|
||||
// Don't override our custom profile with OAuth profile
|
||||
return profile;
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience helpers
|
||||
// OAuth2 provider-specific login methods
|
||||
loginWithMicrosoft(): Promise<void> {
|
||||
const providerConfig = this.configService.setting?.oauthProviders?.microsoft;
|
||||
if (!providerConfig) {
|
||||
throw new Error('Microsoft OAuth configuration not found');
|
||||
}
|
||||
|
||||
const microsoftConfig: Partial<AuthConfig> = {
|
||||
issuer: providerConfig.issuer,
|
||||
clientId: providerConfig.clientId,
|
||||
scope: 'openid profile email https://graph.microsoft.com/User.Read',
|
||||
};
|
||||
return this.startLogin(microsoftConfig);
|
||||
}
|
||||
|
||||
loginWithGoogle(): Promise<void> {
|
||||
const providerConfig = this.configService.setting?.oauthProviders?.google;
|
||||
if (!providerConfig) {
|
||||
throw new Error('Google OAuth configuration not found');
|
||||
}
|
||||
|
||||
const googleConfig: Partial<AuthConfig> = {
|
||||
issuer: providerConfig.issuer,
|
||||
clientId: providerConfig.clientId,
|
||||
scope: 'openid profile email',
|
||||
dummyClientSecret: providerConfig.dummyClientSecret,
|
||||
// Override redirect URI for Google to match what might be registered
|
||||
redirectUri: `${window.location.origin}/authentication/callback`
|
||||
};
|
||||
|
||||
console.log('Google OAuth Config:', JSON.stringify(googleConfig));
|
||||
return this.startLogin(googleConfig);
|
||||
}
|
||||
|
||||
loginWithPocketId(): Promise<void> {
|
||||
const providerConfig = this.configService.setting?.oauthProviders?.pocketid;
|
||||
if (!providerConfig) {
|
||||
throw new Error('PocketId OAuth configuration not found');
|
||||
}
|
||||
|
||||
const pocketIdConfig: Partial<AuthConfig> = {
|
||||
issuer: providerConfig.issuer,
|
||||
clientId: providerConfig.clientId,
|
||||
scope: 'openid profile email',
|
||||
};
|
||||
return this.startLogin(pocketIdConfig);
|
||||
}
|
||||
|
||||
// Token management
|
||||
hasValidCustomToken(): boolean {
|
||||
const token = this.getCustomAccessToken();
|
||||
if (!token) return false;
|
||||
|
||||
try {
|
||||
// Basic JWT expiration check
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return payload.exp > now;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getCustomAccessToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep OAuth methods for backward compatibility
|
||||
get events() {
|
||||
return this.oauthService.events;
|
||||
}
|
||||
|
||||
hasValidAccessToken(): boolean {
|
||||
return this.oauthService.hasValidAccessToken();
|
||||
return this.hasValidCustomToken();
|
||||
}
|
||||
|
||||
getAccessToken(): string {
|
||||
return this.oauthService.getAccessToken();
|
||||
return this.getCustomAccessToken() || '';
|
||||
}
|
||||
|
||||
getIdToken(): string {
|
||||
@@ -114,12 +367,44 @@ export class AuthenticationService {
|
||||
return this.oauthService.getIdentityClaims() as object | null;
|
||||
}
|
||||
|
||||
logout(destroyLocalSession = false) {
|
||||
if (destroyLocalSession) {
|
||||
try {
|
||||
localStorage.removeItem(CONFIG_KEY);
|
||||
} catch { }
|
||||
async logout(destroyLocalSession = true): Promise<void> {
|
||||
try {
|
||||
// Call API logout endpoint if we have a valid token
|
||||
if (this.hasValidCustomToken()) {
|
||||
await firstValueFrom(this.http.post('/api/auth/logout', {}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Logout API call failed:', error);
|
||||
}
|
||||
this.oauthService.logOut();
|
||||
|
||||
this.clearAuth();
|
||||
|
||||
if (destroyLocalSession) {
|
||||
this.oauthService.logOut();
|
||||
}
|
||||
|
||||
this.toastr.success('Logged out successfully', 'Authentication');
|
||||
this.router.navigate(['/authentication/login']);
|
||||
}
|
||||
|
||||
private clearAuth(): void {
|
||||
try {
|
||||
localStorage.removeItem(CONFIG_KEY);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
} catch { }
|
||||
|
||||
this.profile = null;
|
||||
this.userSubject.next(null);
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
isAuthenticated(): boolean {
|
||||
return this.hasValidCustomToken() && this.profile !== null;
|
||||
}
|
||||
|
||||
// Get current user synchronously
|
||||
getCurrentUserSync(): UserProfile | null {
|
||||
return this.profile;
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,11 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<!-- load recaptcha in explicit render mode and call onRecaptchaLoad when ready -->
|
||||
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
|
||||
</head>
|
||||
|
||||
<body class="mat-typography">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user