diff --git a/Foxchat.Chat/Controllers/HelloController.cs b/Foxchat.Chat/Controllers/HelloController.cs new file mode 100644 index 0000000..f0e3ca4 --- /dev/null +++ b/Foxchat.Chat/Controllers/HelloController.cs @@ -0,0 +1,45 @@ +using Foxchat.Chat.Database; +using Foxchat.Chat.Database.Models; +using Foxchat.Chat.Middleware; +using Foxchat.Core.Extensions; +using Foxchat.Core.Federation; +using Foxchat.Core.Models.Http; +using Microsoft.AspNetCore.Mvc; +using ApiError = Foxchat.Core.ApiError; + +namespace Foxchat.Chat.Controllers; + +[ApiController] +[Unauthenticated] +[Route("/_fox/chat/hello")] +public class HelloController( + ILogger logger, + ChatContext db, + InstanceConfig config, + RequestSigningService requestSigningService) + : ControllerBase +{ + [HttpPost] + public async Task Hello([FromBody] Hello.HelloRequest req) + { + var node = await requestSigningService.RequestAsync(HttpMethod.Get, req.Host, + "/_fox/ident/node"); + + if (!HttpContext.ExtractRequestData(out var signature, out var domain, out var signatureData)) + throw new ApiError.IncomingFederationError("This endpoint requires signed requests."); + + if (!requestSigningService.VerifySignature(node.PublicKey, signature, signatureData)) + throw new ApiError.IncomingFederationError("Signature is not valid."); + + var instance = await db.GetInstanceAsync(); + db.IdentityInstances.Add(new IdentityInstance + { + Domain = req.Host, + BaseUrl = $"https://{req.Host}", + PublicKey = node.PublicKey + }); + await db.SaveChangesAsync(); + + return Ok(new Hello.HelloResponse(instance.PublicKey, config.Domain)); + } +} \ No newline at end of file diff --git a/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs b/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs index 13955bd..cd522d8 100644 --- a/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs +++ b/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs @@ -1,6 +1,7 @@ using Foxchat.Chat.Database; using Foxchat.Chat.Database.Models; using Foxchat.Core; +using Foxchat.Core.Extensions; using Foxchat.Core.Federation; using Microsoft.EntityFrameworkCore; @@ -20,7 +21,7 @@ public class AuthenticationMiddleware(ILogger logger, ChatContext db, RequestSig return; } - if (!ExtractRequestData(ctx, out var signature, out var domain, out var signatureData)) + if (!ctx.ExtractRequestData(out var signature, out var domain, out var signatureData)) throw new ApiError.IncomingFederationError("This endpoint requires signed requests."); var instance = await GetInstanceAsync(domain); @@ -38,37 +39,6 @@ public class AuthenticationMiddleware(ILogger logger, ChatContext db, RequestSig return await db.IdentityInstances.FirstOrDefaultAsync(i => i.Domain == domain) ?? throw new ApiError.IncomingFederationError("Remote instance is not known."); } - - private bool ExtractRequestData(HttpContext ctx, out string signature, out string domain, out SignatureData data) - { - signature = string.Empty; - domain = string.Empty; - data = SignatureData.Empty; - - if (!ctx.Request.Headers.TryGetValue(RequestSigningService.SIGNATURE_HEADER, out var encodedSignature)) - return false; - if (!ctx.Request.Headers.TryGetValue(RequestSigningService.DATE_HEADER, out var date)) - return false; - if (!ctx.Request.Headers.TryGetValue(RequestSigningService.SERVER_HEADER, out var server)) - return false; - var time = RequestSigningService.ParseTime(date.ToString()); - string? userId = null; - if (ctx.Request.Headers.TryGetValue(RequestSigningService.USER_HEADER, out var userIdHeader)) - userId = userIdHeader; - var host = ctx.Request.Headers.Host.ToString(); - - signature = encodedSignature.ToString(); - domain = server.ToString(); - data = new SignatureData( - time, - host, - ctx.Request.Path, - (int?)ctx.Request.Headers.ContentLength, - userId - ); - - return true; - } } [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] diff --git a/Foxchat.Chat/Program.cs b/Foxchat.Chat/Program.cs index 11ab291..6d94171 100644 --- a/Foxchat.Chat/Program.cs +++ b/Foxchat.Chat/Program.cs @@ -3,6 +3,7 @@ using Serilog; using Foxchat.Core; using Foxchat.Chat; using Foxchat.Chat.Database; +using Foxchat.Chat.Extensions; using Newtonsoft.Json; var builder = WebApplication.CreateBuilder(args); @@ -33,6 +34,7 @@ builder.Services builder.Services .AddCoreServices() + .AddCustomMiddleware() .AddEndpointsApiExplorer() .AddSwaggerGen(); @@ -43,8 +45,7 @@ app.UseRouting(); app.UseSwagger(); app.UseSwaggerUI(); app.UseCors(); -app.UseAuthentication(); -app.UseAuthorization(); +app.UseCustomMiddleware(); app.MapControllers(); using (var scope = app.Services.CreateScope()) diff --git a/Foxchat.Core/Database/IDatabaseContext.cs b/Foxchat.Core/Database/IDatabaseContext.cs index 9e2a707..a8d8ec5 100644 --- a/Foxchat.Core/Database/IDatabaseContext.cs +++ b/Foxchat.Core/Database/IDatabaseContext.cs @@ -16,11 +16,11 @@ public abstract class IDatabaseContext : DbContext var publicKey = rsa.ExportRSAPublicKeyPem(); var privateKey = rsa.ExportRSAPrivateKeyPem(); - await Instance.AddAsync(new Instance + Instance.Add(new Instance { PublicKey = publicKey!, PrivateKey = privateKey!, - }, ct); + }); await SaveChangesAsync(ct); return true; diff --git a/Foxchat.Core/Extensions/HttpContextExtensions.cs b/Foxchat.Core/Extensions/HttpContextExtensions.cs new file mode 100644 index 0000000..9b5db59 --- /dev/null +++ b/Foxchat.Core/Extensions/HttpContextExtensions.cs @@ -0,0 +1,38 @@ +using Foxchat.Core.Federation; +using Microsoft.AspNetCore.Http; + +namespace Foxchat.Core.Extensions; + +public static class HttpContextExtensions +{ + public static bool ExtractRequestData(this HttpContext ctx, out string signature, out string domain, out SignatureData data) + { + signature = string.Empty; + domain = string.Empty; + data = SignatureData.Empty; + + if (!ctx.Request.Headers.TryGetValue(RequestSigningService.SIGNATURE_HEADER, out var encodedSignature)) + return false; + if (!ctx.Request.Headers.TryGetValue(RequestSigningService.DATE_HEADER, out var date)) + return false; + if (!ctx.Request.Headers.TryGetValue(RequestSigningService.SERVER_HEADER, out var server)) + return false; + var time = RequestSigningService.ParseTime(date.ToString()); + string? userId = null; + if (ctx.Request.Headers.TryGetValue(RequestSigningService.USER_HEADER, out var userIdHeader)) + userId = userIdHeader; + var host = ctx.Request.Headers.Host.ToString(); + + signature = encodedSignature.ToString(); + domain = server.ToString(); + data = new SignatureData( + time, + host, + ctx.Request.Path, + (int?)ctx.Request.Headers.ContentLength, + userId + ); + + return true; + } +} \ No newline at end of file diff --git a/Foxchat.Core/ServiceCollectionExtensions.cs b/Foxchat.Core/Extensions/ServiceCollectionExtensions.cs similarity index 100% rename from Foxchat.Core/ServiceCollectionExtensions.cs rename to Foxchat.Core/Extensions/ServiceCollectionExtensions.cs diff --git a/Foxchat.Core/Models/Http/Hello.cs b/Foxchat.Core/Models/Http/Hello.cs index 69e0b74..1fc2b4b 100644 --- a/Foxchat.Core/Models/Http/Hello.cs +++ b/Foxchat.Core/Models/Http/Hello.cs @@ -4,6 +4,6 @@ public static class Hello { public record HelloRequest(string Host); public record HelloResponse(string PublicKey, string Host); - public record NodeInfo(string Software, string PublicKey); + public record NodeInfo(NodeSoftware Software, string PublicKey); public record NodeSoftware(string Name, string? Version); } diff --git a/Foxchat.Identity/Controllers/NodeController.cs b/Foxchat.Identity/Controllers/NodeController.cs index 4c47503..0f83de9 100644 --- a/Foxchat.Identity/Controllers/NodeController.cs +++ b/Foxchat.Identity/Controllers/NodeController.cs @@ -1,3 +1,4 @@ +using Foxchat.Core; using Foxchat.Core.Models.Http; using Foxchat.Identity.Database; using Foxchat.Identity.Services; @@ -7,15 +8,16 @@ namespace Foxchat.Identity.Controllers; [ApiController] [Route("/_fox/ident/node")] -public class NodeController(IdentityContext db, ChatInstanceResolverService chatInstanceResolverService) : ControllerBase +public class NodeController(IdentityContext db, ChatInstanceResolverService chatInstanceResolverService) + : ControllerBase { - public const string SOFTWARE_NAME = "Foxchat.NET.Identity"; + private const string SoftwareName = "Foxchat.NET.Identity"; [HttpGet] public async Task GetNode() { var instance = await db.GetInstanceAsync(); - return Ok(new Hello.NodeInfo(SOFTWARE_NAME, instance.PublicKey)); + return Ok(new Hello.NodeInfo(new Hello.NodeSoftware(SoftwareName, BuildInfo.Version), instance.PublicKey)); } [HttpGet("{domain}")] @@ -24,4 +26,4 @@ public class NodeController(IdentityContext db, ChatInstanceResolverService chat var instance = await chatInstanceResolverService.ResolveChatInstanceAsync(domain); return Ok(instance); } -} +} \ No newline at end of file diff --git a/Foxchat.Identity/Controllers/Oauth/AppsController.cs b/Foxchat.Identity/Controllers/Oauth/AppsController.cs index 4b6eed4..402b138 100644 --- a/Foxchat.Identity/Controllers/Oauth/AppsController.cs +++ b/Foxchat.Identity/Controllers/Oauth/AppsController.cs @@ -17,7 +17,7 @@ public class AppsController(ILogger logger, IdentityContext db) : ControllerBase public async Task CreateApplication([FromBody] Apps.CreateRequest req) { var app = Application.Create(req.Name, req.Scopes, req.RedirectUris); - await db.AddAsync(app); + db.Add(app); await db.SaveChangesAsync(); logger.Information("Created new application {Name} with ID {Id} and client ID {ClientId}", app.Name, app.Id, app.ClientId); diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs index 927fadf..2d2c729 100644 --- a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs +++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs @@ -21,10 +21,13 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c public async Task Register([FromBody] RegisterRequest req) { var app = HttpContext.GetApplicationOrThrow(); - var appToken = HttpContext.GetToken() ?? throw new UnreachableException(); // GetApplicationOrThrow already gets the token and throws if it's null + var appToken = + HttpContext.GetToken() ?? + throw new UnreachableException(); // GetApplicationOrThrow already gets the token and throws if it's null if (req.Scopes.Except(appToken.Scopes).Any()) - throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", req.Scopes.Except(appToken.Scopes)); + throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", + req.Scopes.Except(appToken.Scopes)); var acct = new Account { @@ -33,12 +36,12 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c Role = Account.AccountRole.User }; - await db.AddAsync(acct); + db.Add(acct); var hashedPassword = await Task.Run(() => _passwordHasher.HashPassword(acct, req.Password)); acct.Password = hashedPassword; // TODO: make token expiry configurable var (tokenStr, token) = Token.Create(acct, app, req.Scopes, clock.GetCurrentInstant() + Duration.FromDays(365)); - await db.AddAsync(token); + db.Add(token); await db.SaveChangesAsync(); return Ok(new AuthResponse(acct.Id, acct.Username, acct.Email, tokenStr)); @@ -51,26 +54,28 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c var appToken = HttpContext.GetToken() ?? throw new UnreachableException(); if (req.Scopes.Except(appToken.Scopes).Any()) - throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", req.Scopes.Except(appToken.Scopes)); + throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", + req.Scopes.Except(appToken.Scopes)); var acct = await db.Accounts.FirstOrDefaultAsync(a => a.Email == req.Email) - ?? throw new ApiError.NotFound("No user with that email found, or password is incorrect"); + ?? throw new ApiError.NotFound("No user with that email found, or password is incorrect"); var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(acct, acct.Password, req.Password)); if (pwResult == PasswordVerificationResult.Failed) throw new ApiError.NotFound("No user with that email found, or password is incorrect"); if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) acct.Password = await Task.Run(() => _passwordHasher.HashPassword(acct, req.Password)); - - + var (tokenStr, token) = Token.Create(acct, app, req.Scopes, clock.GetCurrentInstant() + Duration.FromDays(365)); - await db.AddAsync(token); + db.Add(token); await db.SaveChangesAsync(); return Ok(new AuthResponse(acct.Id, acct.Username, acct.Email, tokenStr)); } public record RegisterRequest(string Username, string Password, string Email, string[] Scopes); + public record LoginRequest(string Email, string Password, string[] Scopes); + public record AuthResponse(Ulid Id, string Username, string Email, string Token); } \ No newline at end of file diff --git a/Foxchat.Identity/Controllers/Oauth/TokenController.cs b/Foxchat.Identity/Controllers/Oauth/TokenController.cs index 829f9ec..b01ee77 100644 --- a/Foxchat.Identity/Controllers/Oauth/TokenController.cs +++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs @@ -41,7 +41,7 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) : var expiry = clock.GetCurrentInstant() + Duration.FromDays(365); var (token, tokenObj) = Token.Create(null, app, scopes, expiry); - await db.AddAsync(tokenObj); + db.Add(tokenObj); await db.SaveChangesAsync(); logger.Debug("Created token with scopes {Scopes} for application {ApplicationId}", scopes, app.Id); diff --git a/Foxchat.Identity/Services/ChatInstanceResolverService.cs b/Foxchat.Identity/Services/ChatInstanceResolverService.cs index 686bfd0..0881321 100644 --- a/Foxchat.Identity/Services/ChatInstanceResolverService.cs +++ b/Foxchat.Identity/Services/ChatInstanceResolverService.cs @@ -31,7 +31,7 @@ public class ChatInstanceResolverService(ILogger logger, RequestSigningService r PublicKey = resp.PublicKey, Status = ChatInstance.InstanceStatus.Active, }; - await db.AddAsync(instance); + db.Add(instance); await db.SaveChangesAsync(); return instance;