chat: add hello controller

This commit is contained in:
sam 2024-05-21 17:45:35 +02:00
parent 6f6e19bbb5
commit 7b4cbd4fb7
12 changed files with 114 additions and 53 deletions

View file

@ -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<IActionResult> Hello([FromBody] Hello.HelloRequest req)
{
var node = await requestSigningService.RequestAsync<Hello.NodeInfo>(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));
}
}

View file

@ -1,6 +1,7 @@
using Foxchat.Chat.Database; using Foxchat.Chat.Database;
using Foxchat.Chat.Database.Models; using Foxchat.Chat.Database.Models;
using Foxchat.Core; using Foxchat.Core;
using Foxchat.Core.Extensions;
using Foxchat.Core.Federation; using Foxchat.Core.Federation;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -20,7 +21,7 @@ public class AuthenticationMiddleware(ILogger logger, ChatContext db, RequestSig
return; 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."); throw new ApiError.IncomingFederationError("This endpoint requires signed requests.");
var instance = await GetInstanceAsync(domain); 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) return await db.IdentityInstances.FirstOrDefaultAsync(i => i.Domain == domain)
?? throw new ApiError.IncomingFederationError("Remote instance is not known."); ?? 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)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]

View file

@ -3,6 +3,7 @@ using Serilog;
using Foxchat.Core; using Foxchat.Core;
using Foxchat.Chat; using Foxchat.Chat;
using Foxchat.Chat.Database; using Foxchat.Chat.Database;
using Foxchat.Chat.Extensions;
using Newtonsoft.Json; using Newtonsoft.Json;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -33,6 +34,7 @@ builder.Services
builder.Services builder.Services
.AddCoreServices<ChatContext>() .AddCoreServices<ChatContext>()
.AddCustomMiddleware()
.AddEndpointsApiExplorer() .AddEndpointsApiExplorer()
.AddSwaggerGen(); .AddSwaggerGen();
@ -43,8 +45,7 @@ app.UseRouting();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseCors(); app.UseCors();
app.UseAuthentication(); app.UseCustomMiddleware();
app.UseAuthorization();
app.MapControllers(); app.MapControllers();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())

View file

@ -16,11 +16,11 @@ public abstract class IDatabaseContext : DbContext
var publicKey = rsa.ExportRSAPublicKeyPem(); var publicKey = rsa.ExportRSAPublicKeyPem();
var privateKey = rsa.ExportRSAPrivateKeyPem(); var privateKey = rsa.ExportRSAPrivateKeyPem();
await Instance.AddAsync(new Instance Instance.Add(new Instance
{ {
PublicKey = publicKey!, PublicKey = publicKey!,
PrivateKey = privateKey!, PrivateKey = privateKey!,
}, ct); });
await SaveChangesAsync(ct); await SaveChangesAsync(ct);
return true; return true;

View file

@ -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;
}
}

View file

@ -4,6 +4,6 @@ public static class Hello
{ {
public record HelloRequest(string Host); public record HelloRequest(string Host);
public record HelloResponse(string PublicKey, 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); public record NodeSoftware(string Name, string? Version);
} }

View file

@ -1,3 +1,4 @@
using Foxchat.Core;
using Foxchat.Core.Models.Http; using Foxchat.Core.Models.Http;
using Foxchat.Identity.Database; using Foxchat.Identity.Database;
using Foxchat.Identity.Services; using Foxchat.Identity.Services;
@ -7,15 +8,16 @@ namespace Foxchat.Identity.Controllers;
[ApiController] [ApiController]
[Route("/_fox/ident/node")] [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] [HttpGet]
public async Task<IActionResult> GetNode() public async Task<IActionResult> GetNode()
{ {
var instance = await db.GetInstanceAsync(); 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}")] [HttpGet("{domain}")]
@ -24,4 +26,4 @@ public class NodeController(IdentityContext db, ChatInstanceResolverService chat
var instance = await chatInstanceResolverService.ResolveChatInstanceAsync(domain); var instance = await chatInstanceResolverService.ResolveChatInstanceAsync(domain);
return Ok(instance); return Ok(instance);
} }
} }

View file

@ -17,7 +17,7 @@ public class AppsController(ILogger logger, IdentityContext db) : ControllerBase
public async Task<IActionResult> CreateApplication([FromBody] Apps.CreateRequest req) public async Task<IActionResult> CreateApplication([FromBody] Apps.CreateRequest req)
{ {
var app = Application.Create(req.Name, req.Scopes, req.RedirectUris); var app = Application.Create(req.Name, req.Scopes, req.RedirectUris);
await db.AddAsync(app); db.Add(app);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.Information("Created new application {Name} with ID {Id} and client ID {ClientId}", app.Name, app.Id, app.ClientId); logger.Information("Created new application {Name} with ID {Id} and client ID {ClientId}", app.Name, app.Id, app.ClientId);

View file

@ -21,10 +21,13 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c
public async Task<IActionResult> Register([FromBody] RegisterRequest req) public async Task<IActionResult> Register([FromBody] RegisterRequest req)
{ {
var app = HttpContext.GetApplicationOrThrow(); 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()) 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 var acct = new Account
{ {
@ -33,12 +36,12 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c
Role = Account.AccountRole.User Role = Account.AccountRole.User
}; };
await db.AddAsync(acct); db.Add(acct);
var hashedPassword = await Task.Run(() => _passwordHasher.HashPassword(acct, req.Password)); var hashedPassword = await Task.Run(() => _passwordHasher.HashPassword(acct, req.Password));
acct.Password = hashedPassword; acct.Password = hashedPassword;
// TODO: make token expiry configurable // TODO: make token expiry configurable
var (tokenStr, token) = Token.Create(acct, app, req.Scopes, clock.GetCurrentInstant() + Duration.FromDays(365)); var (tokenStr, token) = Token.Create(acct, app, req.Scopes, clock.GetCurrentInstant() + Duration.FromDays(365));
await db.AddAsync(token); db.Add(token);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new AuthResponse(acct.Id, acct.Username, acct.Email, tokenStr)); 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(); var appToken = HttpContext.GetToken() ?? throw new UnreachableException();
if (req.Scopes.Except(appToken.Scopes).Any()) 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) 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)); var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(acct, acct.Password, req.Password));
if (pwResult == PasswordVerificationResult.Failed) if (pwResult == PasswordVerificationResult.Failed)
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");
if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) if (pwResult == PasswordVerificationResult.SuccessRehashNeeded)
acct.Password = await Task.Run(() => _passwordHasher.HashPassword(acct, req.Password)); acct.Password = await Task.Run(() => _passwordHasher.HashPassword(acct, req.Password));
var (tokenStr, token) = Token.Create(acct, app, req.Scopes, clock.GetCurrentInstant() + Duration.FromDays(365)); var (tokenStr, token) = Token.Create(acct, app, req.Scopes, clock.GetCurrentInstant() + Duration.FromDays(365));
await db.AddAsync(token); db.Add(token);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new AuthResponse(acct.Id, acct.Username, acct.Email, tokenStr)); return Ok(new AuthResponse(acct.Id, acct.Username, acct.Email, tokenStr));
} }
public record RegisterRequest(string Username, string Password, string Email, string[] Scopes); public record RegisterRequest(string Username, string Password, string Email, string[] Scopes);
public record LoginRequest(string Email, string Password, string[] Scopes); public record LoginRequest(string Email, string Password, string[] Scopes);
public record AuthResponse(Ulid Id, string Username, string Email, string Token); public record AuthResponse(Ulid Id, string Username, string Email, string Token);
} }

View file

@ -41,7 +41,7 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) :
var expiry = clock.GetCurrentInstant() + Duration.FromDays(365); var expiry = clock.GetCurrentInstant() + Duration.FromDays(365);
var (token, tokenObj) = Token.Create(null, app, scopes, expiry); var (token, tokenObj) = Token.Create(null, app, scopes, expiry);
await db.AddAsync(tokenObj); db.Add(tokenObj);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.Debug("Created token with scopes {Scopes} for application {ApplicationId}", scopes, app.Id); logger.Debug("Created token with scopes {Scopes} for application {ApplicationId}", scopes, app.Id);

View file

@ -31,7 +31,7 @@ public class ChatInstanceResolverService(ILogger logger, RequestSigningService r
PublicKey = resp.PublicKey, PublicKey = resp.PublicKey,
Status = ChatInstance.InstanceStatus.Active, Status = ChatInstance.InstanceStatus.Active,
}; };
await db.AddAsync(instance); db.Add(instance);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return instance; return instance;