A few months ago I developed a student project (ASP.NET 8 + React + SQL Server). The project is of the booking.com type (much more simplified, of course!), with the difference that accommodation units that are NOT accessible to people with disabilities cannot be added. In its initial version, I plan for it to be purely informational. Later on, if I see that it has potential, I will add reservation functionality as well. I want to resume work on it and turn it into a fully real / professional website.
For authentication, I used short-lived JWTs + refresh tokens (I stored them in Redis), but I’ve read that for similar projects, Cookie-based authentication + ASP.NET Identity is preferred.
I’m leaving the RedisTokenStore code here for you to take a look at.
public class RedisTokenStore(
ILogger logger,
IConnectionMultiplexer multiplexer) : ITokenStore
{
private readonly IDatabase database = multiplexer.GetDatabase();
private const string RefreshKeyPrefix = "auth:rt:"; // rt:{hash}
private const string UserIndexPrefix = "auth:u:"; // u:{userId}:rt
// Hash fields
private static readonly RedisValue FUserId = "uid";
private static readonly RedisValue FExpires = "exp"; // unix seconds
private static readonly RedisValue FSession = "sid";
// Lua script for atomic rotation:
// - check old exists
// - check owner matches
// - delete old + remove from user set
// - create new hash + set expireat + add to user set
private static readonly LuaScript RotateScript = LuaScript.Prepare(@"
-- KEYS[1] = oldKey
-- KEYS[2] = newKey
-- KEYS[3] = userSetKey
-- ARGV[1] = userId
-- ARGV[2] = expUnix (seconds)
-- ARGV[3] = sessionId ('' if none)
if redis.call('EXISTS', KEYS[1]) == 0 then
return 0
end
local uid = redis.call('HGET', KEYS[1], 'uid')
if uid ~= ARGV[1] then
return -1
end
redis.call('DEL', KEYS[1])
redis.call('SREM', KEYS[3], KEYS[1])
redis.call('HSET', KEYS[2], 'uid', ARGV[1], 'exp', ARGV[2])
if ARGV[3] ~= '' then
redis.call('HSET', KEYS[2], 'sid', ARGV[3])
else
redis.call('HDEL', KEYS[2], 'sid')
end
local exp = tonumber(ARGV[2])
redis.call('EXPIREAT', KEYS[2], exp)
redis.call('SADD', KEYS[3], KEYS[2])
redis.call('EXPIREAT', KEYS[3], exp, 'GT')
return 1
");
//chacked
public async Task StoreRefreshTokenAsync(
string refreshTokenHash,
string userId,
DateTimeOffset expiresAtUtc,
string? sessionId = null,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
var rtKey = RefreshKey(refreshTokenHash);
var userSetKey = UserIndexKey(userId);
var expUnix = expiresAtUtc.ToUnixTimeSeconds();
// Store refresh token record
var entries = sessionId is not null
? [new(FUserId, userId), new(FExpires, expUnix), new(FSession, sessionId)]
: new HashEntry[] { new(FUserId, userId), new(FExpires, expUnix) };
try
{
// Pipeline for fewer roundtrips
var batch = database.CreateBatch();
var setHashTask = batch.HashSetAsync(rtKey, entries);
var setExpireTask = batch.KeyExpireAsync(rtKey, expiresAtUtc.UtcDateTime);
var addIndexTask = batch.SetAddAsync(userSetKey, rtKey.ToString());
// Keep the user index key around at least as long as the token TTL.
// Optional but keeps housekeeping simpler.
var indexExpireTask = batch.KeyExpireAsync(
userSetKey,
expiresAtUtc.UtcDateTime,
ExpireWhen.GreaterThanCurrentExpiry);
batch.Execute();
await Task
.WhenAll(setHashTask, setExpireTask, addIndexTask, indexExpireTask)
.ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "RedisTokenStore.StoreRefreshToken failed. UserId: {UserId}", userId);
throw;
}
}
//chacked
public async Task TryGetRefreshTokenAsync(
string refreshTokenHash,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
var rtKey = RefreshKey(refreshTokenHash);
try
{
// HMGET uid exp sid
var values = await database
.HashGetAsync(rtKey, [FUserId, FExpires, FSession])
.ConfigureAwait(false);
if (values.Length != 3 ||
values[0].IsNullOrEmpty ||
values[1].IsNullOrEmpty)
{
return null;
}
var userId = values[0].ToString();
if (long.TryParse(values[1].ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var expUnix) == false)
{
return null;
}
var expiresAt = DateTimeOffset.FromUnixTimeSeconds(expUnix);
// If Redis TTL is correct, this shouldn't happen often, but it's a safe guard.
if (expiresAt <= DateTimeOffset.UtcNow)
{
await RevokeRefreshTokenAsync(refreshTokenHash, ct);
logger.LogDebug("Expired refresh token encountered and cleaned up. Key: {Key}", rtKey);
return null;
}
var sessionId = values[2].IsNullOrEmpty
? null
: values[2].ToString();
return new RefreshTokenRecord(userId, expiresAt, sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "RedisTokenStore.TryGetRefreshToken failed. Key: {Key}", rtKey);
throw;
}
}
//chacked
public async Task RevokeRefreshTokenAsync(
string refreshTokenHash,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
var rtKey = RefreshKey(refreshTokenHash);
try
{
// Need userId to remove from the user index set.
var userId = await database
.HashGetAsync(rtKey, FUserId)
.ConfigureAwait(false);
// Delete token key
await database
.KeyDeleteAsync(rtKey)
.ConfigureAwait(false);
// Best-effort cleanup from index
if (userId.IsNullOrEmpty == false)
{
var userSetKey = UserIndexKey(userId!.ToString());
await database
.SetRemoveAsync(userSetKey, rtKey.ToString())
.ConfigureAwait(false);
}
}
catch (Exception ex)
{
logger.LogError(ex, "RedisTokenStore.RevokeRefreshToken failed. Key: {Key}", rtKey);
throw;
}
}
//chacked
public async Task RotateRefreshTokenAsync(
string oldRefreshTokenHash,
string newRefreshTokenHash,
string userId,
DateTimeOffset newExpiresAtUtc,
string? sessionId = null,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
var oldKey = RefreshKey(oldRefreshTokenHash);
var newKey = RefreshKey(newRefreshTokenHash);
var userSetKey = UserIndexKey(userId);
var expUnix = newExpiresAtUtc.ToUnixTimeSeconds();
var sid = sessionId ?? string.Empty;
var oldHashPrefix = Prefix(oldRefreshTokenHash);
var newHashPrefix = Prefix(newRefreshTokenHash);
try
{
var redisResult = await RotateScript.EvaluateAsync(
database,
new
{
KEYS = new RedisKey[] { oldKey, newKey, userSetKey },
ARGV = new RedisValue[] { userId, expUnix, sid }
}
).ConfigureAwait(false);
// Robust parse
var resultAsString = redisResult.ToString();
if (int.TryParse(resultAsString, out var result) == false)
{
logger.LogError(
"RedisTokenStore.RotateRefreshToken returned non-int result. UserId: {UserId}, OldHash: {OldHash}, NewHash: {NewHash}, ExpUnix: {ExpUnix}, Result: {Result}",
userId,
oldHashPrefix,
newHashPrefix,
expUnix,
resultAsString);
return false;
}
return result switch
{
1 => true,
-1 => LogOwnerMismatchAndReturnFalse(userId, oldHashPrefix),
_ => false
};
}
catch (Exception ex)
{
logger.LogError(
ex,
"RedisTokenStore.RotateRefreshToken failed. UserId: {UserId}, OldKey: {OldKey}, NewKey: {NewKey}, ExpUnix: {ExpUnix}",
userId,
oldHashPrefix,
newHashPrefix,
expUnix);
throw;
}
}
//chacked
public async Task RevokeAllRefreshTokensForUserAsync(
string userId,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
var userSetKey = UserIndexKey(userId);
try
{
var members = await database
.SetMembersAsync(userSetKey)
.ConfigureAwait(false);
if (members.Length == 0)
{
// Still delete the set key just in case
await database
.KeyDeleteAsync(userSetKey)
.ConfigureAwait(false);
return;
}
// Delete all rt:* keys and then the index set key
var keysToDelete = members
.Select(m => (RedisKey)m.ToString())
.Append(userSetKey)
.ToArray();
var deleted = await database
.KeyDeleteAsync(keysToDelete)
.ConfigureAwait(false);
logger.LogInformation(
"RevokeAllRefreshTokens: attempted={Attempted}, deleted={Deleted}, userId={UserId}",
members.Length,
deleted,
userId);
}
catch (Exception ex)
{
logger.LogError(ex, "RedisTokenStore.RevokeAllRefreshTokensForUser failed. UserId: {UserId}", userId);
throw;
}
}
private static RedisKey RefreshKey(string refreshTokenHash)
=> $"{RefreshKeyPrefix}{refreshTokenHash}";
private static RedisKey UserIndexKey(string userId)
=> $"{UserIndexPrefix}{userId}:rt";
private static string Prefix(string s, int len = 8)
=> string.IsNullOrEmpty(s) ? string.Empty : s[..Math.Min(len, s.Length)];
private bool LogOwnerMismatchAndReturnFalse(string userId, string oldHashPrefix)
{
logger.LogWarning(
"RefreshToken owner mismatch. Possible theft attempt. UserId: {UserId}, OldHashPrefix: {OldHashPrefix}",
userId,
oldHashPrefix);
return false;
}
}
Should I switch to Cookie-based auth + ASP.NET Identity or continue sticking with JWT + refresh tokens?