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.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)]

View file

@ -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<ChatContext>()
.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())

View file

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

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

View file

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

View file

@ -17,7 +17,7 @@ public class AppsController(ILogger logger, IdentityContext db) : ControllerBase
public async Task<IActionResult> 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);

View file

@ -21,10 +21,13 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c
public async Task<IActionResult> 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);
}

View file

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

View file

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