Shared auth scheme

This commit is contained in:
2025-07-13 18:36:51 +08:00
parent e66abe2e0c
commit afdbde951c
34 changed files with 2704 additions and 179 deletions

View File

@@ -0,0 +1,154 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SystemClock = NodaTime.SystemClock;
namespace DysonNetwork.Shared.Auth;
public class DysonTokenAuthOptions : AuthenticationSchemeOptions;
public class DysonTokenAuthHandler(
IOptionsMonitor<DysonTokenAuthOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
AuthService.AuthServiceClient auth
)
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder, clock)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var tokenInfo = _ExtractToken(Request);
if (tokenInfo == null || string.IsNullOrEmpty(tokenInfo.Token))
return AuthenticateResult.Fail("No token was provided.");
try
{
var now = SystemClock.Instance.GetCurrentInstant();
// Validate token and extract session ID
AuthSession session;
try
{
session = await ValidateToken(tokenInfo.Token);
}
catch (InvalidOperationException ex)
{
return AuthenticateResult.Fail(ex.Message);
}
catch (RpcException ex)
{
return AuthenticateResult.Fail($"Remote error: {ex.Status.StatusCode} - {ex.Status.Detail}");
}
// Store user and session in the HttpContext.Items for easy access in controllers
Context.Items["CurrentUser"] = session.Account;
Context.Items["CurrentSession"] = session;
Context.Items["CurrentTokenType"] = tokenInfo.Type.ToString();
// Create claims from the session
var claims = new List<Claim>
{
new("user_id", session.Account.Id),
new("session_id", session.Id),
new("token_type", tokenInfo.Type.ToString())
};
// return AuthenticateResult.Success(ticket);
return AuthenticateResult.NoResult();
}
catch (Exception ex)
{
return AuthenticateResult.Fail($"Authentication failed: {ex.Message}");
}
}
private async Task<AuthSession> ValidateToken(string token)
{
var resp = await auth.AuthenticateAsync(new AuthenticateRequest { Token = token });
if (!resp.Valid) throw new InvalidOperationException(resp.Message);
if (resp.Session == null) throw new InvalidOperationException("Session not found.");
return resp.Session;
}
private static byte[] Base64UrlDecode(string base64Url)
{
var padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
private static TokenInfo? _ExtractToken(HttpRequest request)
{
// Check for token in query parameters
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
{
return new TokenInfo
{
Token = queryToken.ToString(),
Type = TokenType.AuthKey
};
}
// Check for token in Authorization header
var authHeader = request.Headers["Authorization"].ToString();
if (!string.IsNullOrEmpty(authHeader))
{
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var token = authHeader["Bearer ".Length..].Trim();
var parts = token.Split('.');
return new TokenInfo
{
Token = token,
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
};
}
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
Token = authHeader["AtField ".Length..].Trim(),
Type = TokenType.AuthKey
};
}
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
Token = authHeader["AkField ".Length..].Trim(),
Type = TokenType.ApiKey
};
}
}
// Check for token in cookies
if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken))
{
return new TokenInfo
{
Token = cookieToken,
Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
};
}
return null;
}
}

View File

@@ -0,0 +1,35 @@
using dotnet_etcd.interfaces;
using DysonNetwork.Shared.Proto;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace DysonNetwork.Shared.Auth;
public static class DysonAuthStartup
{
public static IServiceCollection AddDysonAuth(
this IServiceCollection services,
IConfiguration configuration
)
{
services.AddSingleton(sp =>
{
var etcdClient = sp.GetRequiredService<IEtcdClient>();
var config = sp.GetRequiredService<IConfiguration>();
var clientCertPath = config["ClientCert:Path"];
var clientKeyPath = config["ClientKey:Path"];
var clientCertPassword = config["ClientCert:Password"];
return GrpcClientHelper.CreateAuthServiceClient(etcdClient, clientCertPath, clientKeyPath, clientCertPassword);
});
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = AuthConstants.SchemeName;
options.DefaultChallengeScheme = AuthConstants.SchemeName;
})
.AddScheme<DysonTokenAuthOptions, DysonTokenAuthHandler>(AuthConstants.SchemeName, _ => { });
return services;
}
}

View File

@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="dotnet-etcd" Version="8.0.1" />
<PackageReference Include="Google.Api.CommonProtos" Version="2.17.0"/>
<PackageReference Include="Google.Api.CommonProtos" Version="2.17.0" />
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
<PackageReference Include="Google.Protobuf.Tools" Version="3.31.1" />
<PackageReference Include="Grpc" Version="2.46.6" />
@@ -17,21 +17,22 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0"/>
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7"/>
<PackageReference Include="NetTopologySuite" Version="2.6.0"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/>
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2"/>
<PackageReference Include="StackExchange.Redis" Version="2.8.41"/>
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Proto\*.proto" ProtoRoot="Proto" GrpcServices="Both" AdditionalFileExtensions="Proto\"/>
<Protobuf Include="Proto\*.proto" ProtoRoot="Proto" GrpcServices="Both" AdditionalFileExtensions="Proto\" />
</ItemGroup>
</Project>

View File

@@ -1,107 +0,0 @@
using Grpc.Core;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using DysonNetwork.Shared.Proto;
using System.Threading.Tasks;
using DysonNetwork.Shared.Auth;
namespace DysonNetwork.Shared.Middleware;
public class AuthMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<AuthMiddleware> _logger;
public AuthMiddleware(RequestDelegate next, ILogger<AuthMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, AuthService.AuthServiceClient authServiceClient)
{
var tokenInfo = _ExtractToken(context.Request);
if (tokenInfo == null || string.IsNullOrEmpty(tokenInfo.Token))
{
await _next(context);
return;
}
try
{
var authSession = await authServiceClient.AuthenticateAsync(new AuthenticateRequest { Token = tokenInfo.Token });
context.Items["AuthSession"] = authSession;
context.Items["CurrentTokenType"] = tokenInfo.Type.ToString();
// Assuming AuthSession contains Account information or can be retrieved
// context.Items["CurrentUser"] = authSession.Account; // You might need to fetch Account separately if not embedded
}
catch (RpcException ex)
{
_logger.LogWarning(ex, "Authentication failed for token: {Token}", tokenInfo.Token);
// Optionally, you can return an unauthorized response here
// context.Response.StatusCode = StatusCodes.Status401Unauthorized;
// return;
}
await _next(context);
}
private TokenInfo? _ExtractToken(HttpRequest request)
{
// Check for token in query parameters
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
{
return new TokenInfo
{
Token = queryToken.ToString(),
Type = TokenType.AuthKey
};
}
// Check for token in Authorization header
var authHeader = request.Headers["Authorization"].ToString();
if (!string.IsNullOrEmpty(authHeader))
{
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var token = authHeader["Bearer ".Length..].Trim();
var parts = token.Split('.');
return new TokenInfo
{
Token = token,
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
};
}
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
Token = authHeader["AtField ".Length..].Trim(),
Type = TokenType.AuthKey
};
}
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
Token = authHeader["AkField ".Length..].Trim(),
Type = TokenType.ApiKey
};
}
}
// Check for token in cookies
if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken))
{
return new TokenInfo
{
Token = cookieToken,
Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
};
}
return null;
}
}

View File

@@ -6,17 +6,21 @@ option csharp_namespace = "DysonNetwork.Shared.Proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/struct.proto";
import 'account.proto';
// Represents a user session
message AuthSession {
string id = 1;
google.protobuf.StringValue label = 2;
google.protobuf.Timestamp last_granted_at = 3;
google.protobuf.Timestamp expired_at = 4;
optional google.protobuf.Timestamp last_granted_at = 3;
optional google.protobuf.Timestamp expired_at = 4;
string account_id = 5;
string challenge_id = 6;
AuthChallenge challenge = 7;
google.protobuf.StringValue app_id = 8;
Account account = 6;
string challenge_id = 7;
AuthChallenge challenge = 8;
google.protobuf.StringValue app_id = 9;
}
// Represents an authentication challenge
@@ -60,9 +64,111 @@ enum ChallengePlatform {
}
service AuthService {
rpc Authenticate(AuthenticateRequest) returns (AuthSession) {}
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse) {}
}
message AuthenticateRequest {
string token = 1;
}
message AuthenticateResponse {
bool valid = 1;
optional string message = 2;
optional AuthSession session = 3;
}
// Permission related messages and services
message PermissionNode {
string id = 1;
string actor = 2;
string area = 3;
string key = 4;
google.protobuf.Value value = 5; // Using Value to represent generic type
google.protobuf.Timestamp expired_at = 6;
google.protobuf.Timestamp affected_at = 7;
string group_id = 8; // Optional group ID
}
message PermissionGroup {
string id = 1;
string name = 2;
google.protobuf.Timestamp created_at = 3;
}
message HasPermissionRequest {
string actor = 1;
string area = 2;
string key = 3;
}
message HasPermissionResponse {
bool has_permission = 1;
}
message GetPermissionRequest {
string actor = 1;
string area = 2;
string key = 3;
}
message GetPermissionResponse {
google.protobuf.Value value = 1; // Using Value to represent generic type
}
message AddPermissionNodeRequest {
string actor = 1;
string area = 2;
string key = 3;
google.protobuf.Value value = 4;
google.protobuf.Timestamp expired_at = 5;
google.protobuf.Timestamp affected_at = 6;
}
message AddPermissionNodeResponse {
PermissionNode node = 1;
}
message AddPermissionNodeToGroupRequest {
PermissionGroup group = 1;
string actor = 2;
string area = 3;
string key = 4;
google.protobuf.Value value = 5;
google.protobuf.Timestamp expired_at = 6;
google.protobuf.Timestamp affected_at = 7;
}
message AddPermissionNodeToGroupResponse {
PermissionNode node = 1;
}
message RemovePermissionNodeRequest {
string actor = 1;
string area = 2;
string key = 3;
}
message RemovePermissionNodeResponse {
bool success = 1;
}
message RemovePermissionNodeFromGroupRequest {
PermissionGroup group = 1;
string actor = 2;
string area = 3;
string key = 4;
}
message RemovePermissionNodeFromGroupResponse {
bool success = 1;
}
service PermissionService {
rpc HasPermission(HasPermissionRequest) returns (HasPermissionResponse) {}
rpc GetPermission(GetPermissionRequest) returns (GetPermissionResponse) {}
rpc AddPermissionNode(AddPermissionNodeRequest) returns (AddPermissionNodeResponse) {}
rpc AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest) returns (AddPermissionNodeToGroupResponse) {}
rpc RemovePermissionNode(RemovePermissionNodeRequest) returns (RemovePermissionNodeResponse) {}
rpc RemovePermissionNodeFromGroup(RemovePermissionNodeFromGroupRequest) returns (RemovePermissionNodeFromGroupResponse) {}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Shared.Registry;
public class RegistryHostedService(
ServiceRegistry serviceRegistry,
IConfiguration configuration,
ILogger<RegistryHostedService> logger
)
: IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
var serviceName = configuration["Service:Name"];
var serviceUrl = configuration["Service:Url"];
if (string.IsNullOrEmpty(serviceUrl) || string.IsNullOrEmpty(serviceName))
{
logger.LogWarning("Service URL or Service Name was not configured. Skipping Etcd registration.");
return;
}
logger.LogInformation("Registering service {ServiceName} at {ServiceUrl} with Etcd.", serviceName, serviceUrl);
try
{
await serviceRegistry.RegisterService(serviceName, serviceUrl);
logger.LogInformation("Service {ServiceName} registered successfully.", serviceName);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to register service {ServiceName} with Etcd.", serviceName);
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
// The lease will expire automatically if the service stops ungracefully.
var serviceName = configuration["Service:Name"];
if (serviceName is not null)
await serviceRegistry.UnregisterService(serviceName);
logger.LogInformation("Service registration hosted service is stopping.");
}
}

View File

@@ -4,9 +4,9 @@ using Microsoft.Extensions.DependencyInjection;
namespace DysonNetwork.Shared.Registry;
public static class EtcdStartup
public static class RegistryStartup
{
public static IServiceCollection AddEtcdService(
public static IServiceCollection AddRegistryService(
this IServiceCollection services,
IConfiguration configuration
)
@@ -17,6 +17,7 @@ public static class EtcdStartup
options.UseInsecureChannel = configuration.GetValue<bool>("Etcd:Insecure");
});
services.AddSingleton<ServiceRegistry>();
services.AddHostedService<RegistryHostedService>();
return services;
}