/* * Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Data.Caching.Extensions * File: ServiceEndpoints.cs * * ServiceEndpoints.cs is part of VNLib.Data.Caching.Extensions which is part of the larger * VNLib collection of libraries and utilities. * * VNLib.Data.Caching.Extensions is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * VNLib.Data.Caching.Extensions is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ using System; using System.Security; using System.Text.Json; using RestSharp; using VNLib.Net.Http; using VNLib.Hashing; using VNLib.Hashing.IdentityUtility; using VNLib.Net.Rest.Client.Construction; using ContentType = VNLib.Net.Http.ContentType; using VNLib.Data.Caching.Extensions.Clustering; namespace VNLib.Data.Caching.Extensions.ApiModel { /* * Defines the cache endpoints, builds and routes request messages to the * server enpoints. In effect defines the client api for cache services. * * This class also define methods for authentication and message verification */ internal static class ServiceEndpoints { private static readonly TimeSpan MaxTimeDisparity = TimeSpan.FromSeconds(10); /// /// Get the endpoint definition for cache services for the site adapter /// internal static IRestEndpointDefinition Definition { get; } = new EndpointBuilder(); private class EndpointBuilder : IRestEndpointDefinition { /// public void BuildRequest(IRestSiteAdapter site, IRestEndpointBuilder builder) { //Define cache service endpoints/requests builder.WithEndpoint() .WithUrl(e => e.DiscoveryUrl) .WithMethod(Method.Get) //Accept text response (it should be a jwt) .WithHeader("Accept", HttpHelpers.GetContentTypeString(ContentType.Text)) .WithHeader("Authorization", BuildDiscoveryAuthToken) //Verify jwt responses .OnResponse(VerifyJwtResponse); builder.WithEndpoint() .WithUrl(e => e.ConnectUrl) .WithMethod(Method.Get) //Accept text response (its should be a jwt) .WithHeader("Accept", HttpHelpers.GetContentTypeString(ContentType.Text)) .WithHeader("Authorization", BuildDiscoveryAuthToken) //Verify jwt responses .OnResponse(VerifyJwtResponse); //Well known endpoint does not require authentication builder.WithEndpoint() .WithUrl(gc => gc.WellKnownEp) .WithMethod(Method.Get) //Responses should be a signed jwt .WithHeader("Accept", HttpHelpers.GetContentTypeString(ContentType.Text)) //Verify jwt responses .OnResponse(VerifyJwtResponse); } } /* * These methods define consitend endpoint definitions that must match the server * endpoints. * * Most requests will send an authorization header with a signed JWT that includes * a random challenge that the server will return in the response. * * All responses will be signed JWTs that must be verified before continuing */ private static string BuildDiscoveryAuthToken(ICacheConnectionRequest request) { request.Challenge = RandomHash.GetRandomBase32(24); //Build request jwt using JsonWebToken jwt = new(); jwt.WriteHeader(request.Config.AuthManager.GetJwtHeader()); //See if the supplied config is for a cache node CacheNodeConfiguration? cnc = request.Config as CacheNodeConfiguration; //Init claim JwtPayload claim = jwt.InitPayloadClaim(); claim.AddClaim("chl", request.Challenge) .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); if (!string.IsNullOrWhiteSpace(cnc?.NodeId)) { /* * The unique node id so the other nodes know to load the * proper event queue for the current server */ claim.AddClaim("sub", cnc.NodeId); } claim.CommitClaims(); //sign the jwt request.Config.AuthManager.SignJwt(jwt); //Compile the jwt return jwt.Compile(); } private static void VerifyJwtResponse(ICacheConnectionRequest req, RestResponse response) { byte[] data = response.RawBytes ?? throw new ArgumentException("Server response was empty, cannot continue"); //If node config then set the is-node flag bool isNode = req.Config is CacheNodeConfiguration; //Response is jwt using JsonWebToken responseJwt = JsonWebToken.ParseRaw(data); //Verify the jwt if (!req.Config.AuthManager.VerifyJwt(responseJwt, isNode)) { throw new SecurityException("Failed to verify the discovery server's challenge, cannot continue"); } //get payload as a document using JsonDocument doc = responseJwt.GetPayload(); //Verify iat times long iatSec = doc.RootElement.GetProperty("iat").GetInt64(); //Get dto DateTimeOffset iat = DateTimeOffset.FromUnixTimeSeconds(iatSec); DateTimeOffset now = DateTimeOffset.UtcNow; //Verify iat is not before or after the current time with the disparity if (iat.Add(MaxTimeDisparity) < now || iat.Subtract(MaxTimeDisparity) > now) { throw new SecurityException("Server returned a request that has expired. Please check your system clock"); } //If a challenge is set, verify it if (req.Challenge != null) { //Verify challenge string challenge = doc.RootElement.GetProperty("chl").GetString() ?? throw new SecurityException("Server did not return a challenge"); if (!challenge.Equals(req.Challenge, StringComparison.Ordinal)) { throw new SecurityException("Server returned an invalid challenge"); } } } } }