Initial commit
This commit is contained in:
17
MealPlan.Api/Services/AuthResult.cs
Normal file
17
MealPlan.Api/Services/AuthResult.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using MealPlan.Api.DTOs.Auth;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>Outcome of an auth operation. Avoids throwing for expected failures (bad credentials, etc.).</summary>
|
||||
public class AuthResult
|
||||
{
|
||||
public bool Succeeded { get; private init; }
|
||||
public string? Error { get; private init; }
|
||||
public TokenResponseDto? Tokens { get; private init; }
|
||||
|
||||
public static AuthResult Success(TokenResponseDto tokens) =>
|
||||
new() { Succeeded = true, Tokens = tokens };
|
||||
|
||||
public static AuthResult Fail(string error) =>
|
||||
new() { Succeeded = false, Error = error };
|
||||
}
|
||||
172
MealPlan.Api/Services/AuthService.cs
Normal file
172
MealPlan.Api/Services/AuthService.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.Auth;
|
||||
using MealPlan.Api.Models;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only authentication against the existing "AspNetUsers" table.
|
||||
/// Password verification uses ASP.NET Core Identity's <see cref="IPasswordHasher{TUser}"/> so
|
||||
/// it is compatible with hashes produced by ASP.NET Core Identity.
|
||||
/// </summary>
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ITokenService _tokenService;
|
||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
||||
private readonly IPasswordHasher<ApplicationUser> _passwordHasher;
|
||||
private readonly ILogger<AuthService> _logger;
|
||||
|
||||
public AuthService(
|
||||
AppDbContext db,
|
||||
ITokenService tokenService,
|
||||
IRefreshTokenStore refreshTokenStore,
|
||||
IPasswordHasher<ApplicationUser> passwordHasher,
|
||||
ILogger<AuthService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_tokenService = tokenService;
|
||||
_refreshTokenStore = refreshTokenStore;
|
||||
_passwordHasher = passwordHasher;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AuthResult> RegisterAsync(RegisterRequestDto request, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
|
||||
|
||||
var emailTaken = await _db.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(u => u.Email != null && u.Email.ToLower() == normalizedEmail, ct);
|
||||
|
||||
if (emailTaken)
|
||||
{
|
||||
return AuthResult.Fail("An account with this email already exists.");
|
||||
}
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = request.Email.Trim(),
|
||||
UserName = string.IsNullOrWhiteSpace(request.UserName)
|
||||
? request.Email.Trim()
|
||||
: request.UserName.Trim(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true,
|
||||
};
|
||||
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, request.Password);
|
||||
|
||||
_db.Users.Add(user);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation("User {UserId} registered with email {Email}", user.Id, user.Email);
|
||||
return AuthResult.Success(IssueTokens(user));
|
||||
}
|
||||
|
||||
public async Task<AuthResult> LoginAsync(LoginRequestDto request, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedEmail = request.Email.Trim();
|
||||
|
||||
var user = await _db.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.Email != null && u.Email.ToLower() == normalizedEmail.ToLower(), ct);
|
||||
|
||||
// Uniform failure message to avoid user enumeration.
|
||||
const string invalidCredentials = "Invalid email or password.";
|
||||
|
||||
if (user is null || string.IsNullOrEmpty(user.PasswordHash))
|
||||
{
|
||||
_logger.LogWarning("Login failed: user not found for {Email}", normalizedEmail);
|
||||
return AuthResult.Fail(invalidCredentials);
|
||||
}
|
||||
|
||||
if (!user.IsActive)
|
||||
{
|
||||
_logger.LogWarning("Login failed: inactive account {UserId}", user.Id);
|
||||
return AuthResult.Fail("This account is disabled.");
|
||||
}
|
||||
|
||||
var verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
||||
if (verification == PasswordVerificationResult.Failed)
|
||||
{
|
||||
_logger.LogWarning("Login failed: bad password for {UserId}", user.Id);
|
||||
return AuthResult.Fail(invalidCredentials);
|
||||
}
|
||||
|
||||
_logger.LogInformation("User {UserId} logged in", user.Id);
|
||||
return AuthResult.Success(IssueTokens(user));
|
||||
}
|
||||
|
||||
public async Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
var existing = _refreshTokenStore.Get(refreshToken);
|
||||
if (existing is null || !existing.IsActive)
|
||||
{
|
||||
_logger.LogWarning("Refresh failed: token missing or inactive");
|
||||
return AuthResult.Fail("Invalid or expired refresh token.");
|
||||
}
|
||||
|
||||
// Rotation: immediately invalidate the presented token.
|
||||
_refreshTokenStore.Revoke(refreshToken);
|
||||
|
||||
if (!Guid.TryParse(existing.UserId, out var refreshUserId))
|
||||
{
|
||||
return AuthResult.Fail("Invalid or expired refresh token.");
|
||||
}
|
||||
|
||||
var user = await _db.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.Id == refreshUserId, ct);
|
||||
|
||||
if (user is null || !user.IsActive)
|
||||
{
|
||||
_logger.LogWarning("Refresh failed: user {UserId} missing or inactive", existing.UserId);
|
||||
return AuthResult.Fail("Invalid or expired refresh token.");
|
||||
}
|
||||
|
||||
return AuthResult.Success(IssueTokens(user));
|
||||
}
|
||||
|
||||
public void Logout(string refreshToken) => _refreshTokenStore.Revoke(refreshToken);
|
||||
|
||||
public async Task<UserInfoDto?> GetUserInfoAsync(string userId, CancellationToken ct = default)
|
||||
{
|
||||
if (!Guid.TryParse(userId, out var parsedUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var user = await _db.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.Id == parsedUserId, ct);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new UserInfoDto
|
||||
{
|
||||
Id = user.Id.ToString(),
|
||||
UserName = user.UserName,
|
||||
Email = user.Email,
|
||||
};
|
||||
}
|
||||
|
||||
private TokenResponseDto IssueTokens(ApplicationUser user)
|
||||
{
|
||||
var accessToken = _tokenService.CreateAccessToken(user);
|
||||
var refreshToken = _tokenService.CreateRefreshToken(user.Id.ToString());
|
||||
|
||||
return new TokenResponseDto
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken.Token,
|
||||
ExpiresInSeconds = _tokenService.AccessTokenLifetimeSeconds,
|
||||
TokenType = "Bearer",
|
||||
};
|
||||
}
|
||||
}
|
||||
18
MealPlan.Api/Services/IAuthService.cs
Normal file
18
MealPlan.Api/Services/IAuthService.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using MealPlan.Api.DTOs.Auth;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthResult> RegisterAsync(RegisterRequestDto request, CancellationToken ct = default);
|
||||
|
||||
Task<AuthResult> LoginAsync(LoginRequestDto request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Validates and rotates a refresh token, issuing a fresh token pair.</summary>
|
||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Revokes the supplied refresh token. Idempotent.</summary>
|
||||
void Logout(string refreshToken);
|
||||
|
||||
Task<UserInfoDto?> GetUserInfoAsync(string userId, CancellationToken ct = default);
|
||||
}
|
||||
12
MealPlan.Api/Services/IRecipeService.cs
Normal file
12
MealPlan.Api/Services/IRecipeService.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
public interface IRecipeService
|
||||
{
|
||||
Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(int? category, string? search, CancellationToken ct = default);
|
||||
|
||||
Task<RecipeDetailDto?> GetRecipeByIdAsync(int id, CancellationToken ct = default);
|
||||
|
||||
Task<IReadOnlyList<CategoryCountDto>> GetCategoriesAsync(CancellationToken ct = default);
|
||||
}
|
||||
20
MealPlan.Api/Services/IRefreshTokenStore.cs
Normal file
20
MealPlan.Api/Services/IRefreshTokenStore.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using MealPlan.Api.Models;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Stores issued refresh tokens. Backed by an in-memory implementation because the
|
||||
/// existing database schema must not be modified and no migrations may be added.
|
||||
/// </summary>
|
||||
public interface IRefreshTokenStore
|
||||
{
|
||||
void Add(RefreshToken token);
|
||||
|
||||
RefreshToken? Get(string token);
|
||||
|
||||
/// <summary>Marks a token revoked. Returns the revoked token if it existed and was active.</summary>
|
||||
RefreshToken? Revoke(string token);
|
||||
|
||||
/// <summary>Removes expired/revoked tokens to bound memory usage.</summary>
|
||||
void RemoveExpired();
|
||||
}
|
||||
16
MealPlan.Api/Services/ITokenService.cs
Normal file
16
MealPlan.Api/Services/ITokenService.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Security.Claims;
|
||||
using MealPlan.Api.Models;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
/// <summary>Creates a signed JWT access token for the given user.</summary>
|
||||
string CreateAccessToken(ApplicationUser user);
|
||||
|
||||
/// <summary>Generates and persists a new refresh token for the given user id.</summary>
|
||||
RefreshToken CreateRefreshToken(string userId);
|
||||
|
||||
/// <summary>Access-token lifetime in seconds (exposed to clients for scheduling refresh).</summary>
|
||||
int AccessTokenLifetimeSeconds { get; }
|
||||
}
|
||||
43
MealPlan.Api/Services/InMemoryRefreshTokenStore.cs
Normal file
43
MealPlan.Api/Services/InMemoryRefreshTokenStore.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Concurrent;
|
||||
using MealPlan.Api.Models;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe in-memory refresh token store registered as a singleton.
|
||||
/// Note: tokens live only for the lifetime of the process. For multi-instance
|
||||
/// deployments this should be swapped for a shared store (e.g. Redis), but it
|
||||
/// satisfies the "do not modify the database" constraint of this project.
|
||||
/// </summary>
|
||||
public class InMemoryRefreshTokenStore : IRefreshTokenStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, RefreshToken> _tokens = new();
|
||||
|
||||
public void Add(RefreshToken token) => _tokens[token.Token] = token;
|
||||
|
||||
public RefreshToken? Get(string token) =>
|
||||
_tokens.TryGetValue(token, out var found) ? found : null;
|
||||
|
||||
public RefreshToken? Revoke(string token)
|
||||
{
|
||||
if (!_tokens.TryGetValue(token, out var found))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var wasActive = found.IsActive;
|
||||
found.IsRevoked = true;
|
||||
return wasActive ? found : null;
|
||||
}
|
||||
|
||||
public void RemoveExpired()
|
||||
{
|
||||
foreach (var kvp in _tokens)
|
||||
{
|
||||
if (kvp.Value.IsRevoked || DateTime.UtcNow >= kvp.Value.ExpiresAtUtc)
|
||||
{
|
||||
_tokens.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
MealPlan.Api/Services/JwtOptions.cs
Normal file
17
MealPlan.Api/Services/JwtOptions.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>Strongly-typed JWT settings bound from the "Jwt" configuration section / env vars.</summary>
|
||||
public class JwtOptions
|
||||
{
|
||||
public const string SectionName = "Jwt";
|
||||
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
public string Audience { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Access-token lifetime in minutes. Defaults to 15 per the security spec.</summary>
|
||||
public int AccessTokenMinutes { get; set; } = 15;
|
||||
|
||||
/// <summary>Refresh-token lifetime in days. Defaults to 7 per the security spec.</summary>
|
||||
public int RefreshTokenDays { get; set; } = 7;
|
||||
}
|
||||
107
MealPlan.Api/Services/RecipeService.cs
Normal file
107
MealPlan.Api/Services/RecipeService.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
using MealPlan.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only recipe queries. All filtering is done with EF Core LINQ (parameterized);
|
||||
/// no raw SQL is used anywhere.
|
||||
/// </summary>
|
||||
public class RecipeService : IRecipeService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public RecipeService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(
|
||||
int? category, string? search, CancellationToken ct = default)
|
||||
{
|
||||
var query = _db.Recipes.AsNoTracking().Where(r => r.IsActive);
|
||||
|
||||
if (category is >= 0 and <= 3)
|
||||
{
|
||||
query = query.Where(r => r.MealCategory == category);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
query = query.Where(r => EF.Functions.ILike(r.Name, $"%{term}%"));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(r => r.Name)
|
||||
.Select(r => new RecipeListItemDto
|
||||
{
|
||||
Id = r.Id,
|
||||
Name = r.Name,
|
||||
MealCategory = r.MealCategory,
|
||||
Calories = r.Calories,
|
||||
PrepTimeMinutes = r.PrepTimeMinutes,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<RecipeDetailDto?> GetRecipeByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
return await _db.Recipes
|
||||
.AsNoTracking()
|
||||
.Where(r => r.Id == id && r.IsActive)
|
||||
.Select(r => new RecipeDetailDto
|
||||
{
|
||||
Id = r.Id,
|
||||
Name = r.Name,
|
||||
MealCategory = r.MealCategory,
|
||||
Calories = r.Calories,
|
||||
Protein = r.Protein,
|
||||
Fat = r.Fat,
|
||||
Carbs = r.Carbs,
|
||||
PrepTimeMinutes = r.PrepTimeMinutes,
|
||||
Ingredients = r.Ingredients
|
||||
.OrderBy(i => i.SortOrder)
|
||||
.ThenBy(i => i.Id)
|
||||
.Select(i => new IngredientDto
|
||||
{
|
||||
Id = i.Id,
|
||||
Name = i.Name,
|
||||
AmountGrams = i.AmountGrams,
|
||||
Unit = i.Unit,
|
||||
SortOrder = i.SortOrder,
|
||||
})
|
||||
.ToList(),
|
||||
Steps = r.Steps
|
||||
.OrderBy(s => s.StepNumber)
|
||||
.Select(s => new RecipeStepDto
|
||||
{
|
||||
Id = s.Id,
|
||||
StepNumber = s.StepNumber,
|
||||
Description = s.Description,
|
||||
})
|
||||
.ToList(),
|
||||
})
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CategoryCountDto>> GetCategoriesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var counts = await _db.Recipes
|
||||
.AsNoTracking()
|
||||
.Where(r => r.IsActive)
|
||||
.GroupBy(r => r.MealCategory)
|
||||
.Select(g => new { Category = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Always return all four categories, even when a category has zero recipes.
|
||||
return Enum.GetValues<MealCategory>()
|
||||
.Select(category => new CategoryCountDto
|
||||
{
|
||||
Category = (int)category,
|
||||
Name = category.ToString(),
|
||||
Count = counts.FirstOrDefault(c => c.Category == (int)category)?.Count ?? 0,
|
||||
})
|
||||
.OrderBy(c => c.Category)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
35
MealPlan.Api/Services/RefreshTokenCleanupService.cs
Normal file
35
MealPlan.Api/Services/RefreshTokenCleanupService.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Periodically purges expired and revoked refresh tokens from the in-memory store.
|
||||
/// </summary>
|
||||
public class RefreshTokenCleanupService : BackgroundService
|
||||
{
|
||||
private readonly IRefreshTokenStore _store;
|
||||
private readonly ILogger<RefreshTokenCleanupService> _logger;
|
||||
|
||||
public RefreshTokenCleanupService(
|
||||
IRefreshTokenStore store,
|
||||
ILogger<RefreshTokenCleanupService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_store.RemoveExpired();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Refresh token cleanup failed");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
82
MealPlan.Api/Services/TokenService.cs
Normal file
82
MealPlan.Api/Services/TokenService.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MealPlan.Api.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Issues JWT access tokens and rotating refresh tokens.
|
||||
/// Refresh tokens are cryptographically random opaque strings tracked in <see cref="IRefreshTokenStore"/>.
|
||||
/// </summary>
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
private readonly JwtOptions _options;
|
||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
||||
|
||||
public TokenService(IOptions<JwtOptions> options, IRefreshTokenStore refreshTokenStore)
|
||||
{
|
||||
_options = options.Value;
|
||||
_refreshTokenStore = refreshTokenStore;
|
||||
}
|
||||
|
||||
public int AccessTokenLifetimeSeconds => _options.AccessTokenMinutes * 60;
|
||||
|
||||
public string CreateAccessToken(ApplicationUser user)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var userId = user.Id.ToString();
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new(ClaimTypes.NameIdentifier, userId),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.Email))
|
||||
{
|
||||
claims.Add(new Claim(JwtRegisteredClaimNames.Email, user.Email));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.UserName))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Name, user.UserName));
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: DateTime.UtcNow,
|
||||
expires: DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public RefreshToken CreateRefreshToken(string userId)
|
||||
{
|
||||
var token = new RefreshToken
|
||||
{
|
||||
Token = GenerateSecureToken(),
|
||||
UserId = userId,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
ExpiresAtUtc = DateTime.UtcNow.AddDays(_options.RefreshTokenDays),
|
||||
IsRevoked = false,
|
||||
};
|
||||
|
||||
_refreshTokenStore.Add(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
private static string GenerateSecureToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(64);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user