first commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
namespace SequenceAuth.Lib;
|
||||
|
||||
public interface ISequenceStore
|
||||
{
|
||||
Task<Option<SequenceData>> GetSequenceAsync(string sequenceId);
|
||||
Task<StoreOutcome> SaveSequenceAsync(string sequenceId, SequenceData data);
|
||||
Task<StoreOutcome> InvalidateUserSessionsAsync(string userId);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace SequenceAuth.Lib;
|
||||
|
||||
public enum SequenceState
|
||||
{
|
||||
Initialized,
|
||||
Active,
|
||||
Rotated,
|
||||
Compromised
|
||||
}
|
||||
|
||||
public enum ValidationOutcome
|
||||
{
|
||||
Success,
|
||||
SequenceNotFound,
|
||||
LimitExceeded,
|
||||
CompromisedSequenceDetected,
|
||||
InternalError
|
||||
}
|
||||
|
||||
public enum OptionState
|
||||
{
|
||||
Some,
|
||||
None
|
||||
}
|
||||
|
||||
public record Option<T>(T? Value, OptionState State)
|
||||
{
|
||||
public static Option<T> Some(T value) => new(value, OptionState.Some);
|
||||
public static Option<T> None() => new(default, OptionState.None);
|
||||
}
|
||||
|
||||
public enum StoreOutcome
|
||||
{
|
||||
Success,
|
||||
Failure
|
||||
}
|
||||
|
||||
public record SequenceData(string UserId, int RequestsRemaining, SequenceState State, string NextSequenceId);
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<!-- NuGet Package Metadata -->
|
||||
<PackageId>SequenceAuth</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>YourName</Authors>
|
||||
<Description>A C# library for Sequence Token Rotation authorization using Redis.</Description>
|
||||
<RepositoryUrl>https://github.com/your-repo/SequenceAuth</RepositoryUrl>
|
||||
<PackageTags>authentication;redis;security</PackageTags>
|
||||
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Use FrameworkReference for ASP.NET Core types (like HttpContext) instead of the outdated NuGet package -->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
|
||||
namespace SequenceAuth.Lib;
|
||||
|
||||
public static class SequenceAuthExtensions
|
||||
{
|
||||
public static IServiceCollection AddSequenceAuth(this IServiceCollection services, Action<SequenceAuthOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.Configure(configureOptions);
|
||||
services.AddScoped<SequenceManager>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace SequenceAuth.Lib;
|
||||
|
||||
public class SequenceAuthMiddleware(RequestDelegate next, IOptions<SequenceAuthOptions> options)
|
||||
{
|
||||
private readonly SequenceAuthOptions _options = options?.Value ?? throw new InvalidOperationException("SequenceAuthOptions must be registered in DI using AddSequenceAuth.");
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, SequenceManager sequenceManager)
|
||||
{
|
||||
await (context.Request.Method switch
|
||||
{
|
||||
"OPTIONS" => next(context),
|
||||
_ => ProcessPath(context, sequenceManager)
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ProcessPath(HttpContext context, SequenceManager sequenceManager)
|
||||
{
|
||||
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
|
||||
var isIgnored = _options.IgnoredPaths.FirstOrDefault(x => path.StartsWith(x));
|
||||
await (isIgnored switch
|
||||
{
|
||||
null => CheckHeader(context, sequenceManager),
|
||||
_ => next(context)
|
||||
});
|
||||
}
|
||||
|
||||
private async Task CheckHeader(HttpContext context, SequenceManager sequenceManager)
|
||||
{
|
||||
var headerCount = context.Request.Headers[_options.AuthHeaderName].Count;
|
||||
|
||||
await (headerCount switch
|
||||
{
|
||||
0 => HandleMissingHeader(context),
|
||||
> 0 => ProcessWithHeader(context, sequenceManager, context.Request.Headers[_options.AuthHeaderName].ToString()!),
|
||||
_ => HandleMissingHeader(context)
|
||||
});
|
||||
}
|
||||
|
||||
private Task HandleMissingHeader(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ProcessWithHeader(HttpContext context, SequenceManager sequenceManager, string sequenceId)
|
||||
{
|
||||
var result = await sequenceManager.ValidateAndRotateAsync(sequenceId);
|
||||
|
||||
await (result.Outcome switch
|
||||
{
|
||||
ValidationOutcome.Success => HandleSuccess(context, result.NextSequence, result.UserId, result.RequestsRemaining),
|
||||
_ => HandleFailure(context, result.Outcome)
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleSuccess(HttpContext context, Option<string> nextSequenceOpt, Option<string> userIdOpt, Option<int> requestsRemainingOpt)
|
||||
{
|
||||
_ = nextSequenceOpt.State switch
|
||||
{
|
||||
OptionState.Some => AddHeader(context, nextSequenceOpt.Value!),
|
||||
OptionState.None => 0,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
_ = userIdOpt.State switch
|
||||
{
|
||||
OptionState.Some => AddUserContext(context, userIdOpt.Value!),
|
||||
OptionState.None => 0,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
_ = requestsRemainingOpt.State switch
|
||||
{
|
||||
OptionState.Some => AddRemainingHeader(context, requestsRemainingOpt.Value),
|
||||
OptionState.None => 0,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
await next(context);
|
||||
}
|
||||
|
||||
private int AddRemainingHeader(HttpContext context, int remaining)
|
||||
{
|
||||
context.Response.Headers.Append(_options.RequestsRemainingHeaderName, remaining.ToString());
|
||||
return 1;
|
||||
}
|
||||
|
||||
private int AddHeader(HttpContext context, string value)
|
||||
{
|
||||
context.Response.Headers.Append(_options.NextHeaderName, value);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private int AddUserContext(HttpContext context, string userId)
|
||||
{
|
||||
context.Items[_options.UserIdItemKey] = userId;
|
||||
return 1;
|
||||
}
|
||||
|
||||
private Task HandleFailure(HttpContext context, ValidationOutcome outcome)
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SequenceAuth.Lib;
|
||||
|
||||
public class SequenceAuthOptions
|
||||
{
|
||||
public virtual string UserIdItemKey { get; set; } = "UserId";
|
||||
public virtual string AuthHeaderName { get; set; } = "X-Auth-Seq";
|
||||
public virtual string NextHeaderName { get; set; } = "X-Next-Seq";
|
||||
public virtual string RequestsRemainingHeaderName { get; set; } = "X-Requests-Remaining";
|
||||
|
||||
public virtual HashSet<string> IgnoredPaths { get; set; } = ["/scalar", "/openapi", "/favicon.ico", "/swagger"];
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace SequenceAuth.Lib;
|
||||
|
||||
public record SequenceValidationResult(ValidationOutcome Outcome, Option<string> NextSequence, Option<string> UserId, Option<int> RequestsRemaining);
|
||||
|
||||
public class SequenceManager(ISequenceStore store)
|
||||
{
|
||||
public async Task<SequenceValidationResult> ValidateAndRotateAsync(string currentSequenceId)
|
||||
{
|
||||
var sequenceOpt = await store.GetSequenceAsync(currentSequenceId);
|
||||
|
||||
return sequenceOpt.State switch
|
||||
{
|
||||
OptionState.None => new SequenceValidationResult(ValidationOutcome.SequenceNotFound, Option<string>.None(), Option<string>.None(), Option<int>.None()),
|
||||
OptionState.Some => await ProcessSequenceAsync(currentSequenceId, sequenceOpt.Value!),
|
||||
_ => new SequenceValidationResult(ValidationOutcome.InternalError, Option<string>.None(), Option<string>.None(), Option<int>.None())
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SequenceValidationResult> ProcessSequenceAsync(string currentSequenceId, SequenceData data)
|
||||
{
|
||||
return data.State switch
|
||||
{
|
||||
SequenceState.Compromised => new SequenceValidationResult(ValidationOutcome.CompromisedSequenceDetected, Option<string>.None(), Option<string>.None(), Option<int>.None()),
|
||||
SequenceState.Rotated => await HandleRotatedSequenceAsync(data.UserId),
|
||||
SequenceState.Active or SequenceState.Initialized => await HandleActiveSequenceAsync(currentSequenceId, data),
|
||||
_ => new SequenceValidationResult(ValidationOutcome.InternalError, Option<string>.None(), Option<string>.None(), Option<int>.None())
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SequenceValidationResult> HandleRotatedSequenceAsync(string userId)
|
||||
{
|
||||
await store.InvalidateUserSessionsAsync(userId);
|
||||
return new SequenceValidationResult(ValidationOutcome.CompromisedSequenceDetected, Option<string>.None(), Option<string>.None(), Option<int>.None());
|
||||
}
|
||||
|
||||
private async Task<SequenceValidationResult> HandleActiveSequenceAsync(string currentSequenceId, SequenceData data)
|
||||
{
|
||||
var checkLimit = CheckLimit(data.RequestsRemaining);
|
||||
return checkLimit switch
|
||||
{
|
||||
ValidationOutcome.LimitExceeded => new SequenceValidationResult(ValidationOutcome.LimitExceeded, Option<string>.None(), Option<string>.None(), Option<int>.None()),
|
||||
ValidationOutcome.Success => await RotateSequenceAsync(currentSequenceId, data),
|
||||
_ => new SequenceValidationResult(ValidationOutcome.InternalError, Option<string>.None(), Option<string>.None(), Option<int>.None())
|
||||
};
|
||||
}
|
||||
|
||||
private ValidationOutcome CheckLimit(int requestsRemaining)
|
||||
{
|
||||
return requestsRemaining switch
|
||||
{
|
||||
<= 0 => ValidationOutcome.LimitExceeded,
|
||||
> 0 => ValidationOutcome.Success
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SequenceValidationResult> RotateSequenceAsync(string currentSequenceId, SequenceData data)
|
||||
{
|
||||
var futureSequenceId = Guid.NewGuid().ToString("N");
|
||||
var oldDataRotated = data with { State = SequenceState.Rotated };
|
||||
await store.SaveSequenceAsync(currentSequenceId, oldDataRotated);
|
||||
|
||||
var newData = new SequenceData(
|
||||
UserId: data.UserId,
|
||||
RequestsRemaining: data.RequestsRemaining - 1,
|
||||
State: SequenceState.Active,
|
||||
NextSequenceId: futureSequenceId);
|
||||
|
||||
await store.SaveSequenceAsync(futureSequenceId, newData);
|
||||
|
||||
return new SequenceValidationResult(ValidationOutcome.Success, Option<string>.Some(futureSequenceId), Option<string>.Some(data.UserId), Option<int>.Some(data.RequestsRemaining - 1));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user