chat: add hello controller
This commit is contained in:
parent
6f6e19bbb5
commit
7b4cbd4fb7
12 changed files with 114 additions and 53 deletions
45
Foxchat.Chat/Controllers/HelloController.cs
Normal file
45
Foxchat.Chat/Controllers/HelloController.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)]
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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;
|
||||||
|
|
38
Foxchat.Core/Extensions/HttpContextExtensions.cs
Normal file
38
Foxchat.Core/Extensions/HttpContextExtensions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}")]
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,7 +54,8 @@ 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");
|
||||||
|
@ -62,15 +66,16 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c
|
||||||
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);
|
||||||
}
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue