From 7a0247b55158f19dbe48ae3c9bc59fc63252d732 Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 20 May 2024 19:42:04 +0200 Subject: [PATCH 01/11] add error handler middleware --- .../RequestSigningService.Client.cs | 18 +--- Foxchat.Core/FoxchatError.cs | 12 +-- Foxchat.Core/Models/ApiError.cs | 17 ---- Foxchat.Core/Models/Http/ApiError.cs | 28 ++++++ .../Controllers/Oauth/AppsController.cs | 7 +- .../Oauth/PasswordAuthController.cs | 22 +++++ .../Controllers/Oauth/TokenController.cs | 2 +- .../Extensions/WebApplicationExtensions.cs | 4 +- .../AuthenticationMiddleware.cs | 2 +- .../AuthorizationMiddleware.cs | 3 +- .../Middleware/ErrorHandlerMiddleware.cs | 87 +++++++++++++++++++ Foxchat.Identity/Program.cs | 10 +++ Foxchat.Identity/Utils/OauthUtils.cs | 11 +++ 13 files changed, 177 insertions(+), 46 deletions(-) delete mode 100644 Foxchat.Core/Models/ApiError.cs create mode 100644 Foxchat.Core/Models/Http/ApiError.cs create mode 100644 Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs rename Foxchat.Identity/{Authorization => Middleware}/AuthenticationMiddleware.cs (98%) rename Foxchat.Identity/{Authorization => Middleware}/AuthorizationMiddleware.cs (94%) create mode 100644 Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs diff --git a/Foxchat.Core/Federation/RequestSigningService.Client.cs b/Foxchat.Core/Federation/RequestSigningService.Client.cs index 36de79e..a38e89b 100644 --- a/Foxchat.Core/Federation/RequestSigningService.Client.cs +++ b/Foxchat.Core/Federation/RequestSigningService.Client.cs @@ -1,5 +1,4 @@ using System.Net.Http.Headers; -using Foxchat.Core.Models; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -15,14 +14,6 @@ public partial class RequestSigningService public const string SIGNATURE_HEADER = "X-Foxchat-Signature"; public const string USER_HEADER = "X-Foxchat-User"; - private static readonly JsonSerializerSettings _jsonSerializerSettings = new() - { - ContractResolver = new DefaultContractResolver - { - NamingStrategy = new SnakeCaseNamingStrategy() - } - }; - public async Task RequestAsync(HttpMethod method, string domain, string requestPath, string? userId = null, object? body = null) { var request = BuildHttpRequest(method, domain, requestPath, userId, body); @@ -30,17 +21,17 @@ public partial class RequestSigningService if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(); - throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", DeserializeObject(error)); + throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", JsonConvert.DeserializeObject(error)); } var bodyString = await resp.Content.ReadAsStringAsync(); - return DeserializeObject(bodyString) + return JsonConvert.DeserializeObject(bodyString) ?? throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned invalid response body"); } private HttpRequestMessage BuildHttpRequest(HttpMethod method, string domain, string requestPath, string? userId = null, object? bodyData = null) { - var body = bodyData != null ? SerializeObject(bodyData) : null; + var body = bodyData != null ? JsonConvert.SerializeObject(bodyData) : null; var now = _clock.GetCurrentInstant(); var url = $"https://{domain}{requestPath}"; @@ -59,7 +50,4 @@ public partial class RequestSigningService return request; } - - public static string SerializeObject(object data) => JsonConvert.SerializeObject(data, _jsonSerializerSettings); - public static T? DeserializeObject(string data) => JsonConvert.DeserializeObject(data, _jsonSerializerSettings); } diff --git a/Foxchat.Core/FoxchatError.cs b/Foxchat.Core/FoxchatError.cs index 7fcd201..6c5324b 100644 --- a/Foxchat.Core/FoxchatError.cs +++ b/Foxchat.Core/FoxchatError.cs @@ -2,10 +2,12 @@ using System.Net; namespace Foxchat.Core; -public class FoxchatError(string message) : Exception(message) +public class FoxchatError(string message, Exception? inner = null) : Exception(message) { - public class DatabaseError(string message) : FoxchatError(message); - public class UnknownEntityError(Type entityType) : FoxchatError($"Entity of type {entityType.Name} not found"); + public Exception? Inner => inner; + + public class DatabaseError(string message, Exception? inner = null) : FoxchatError(message, inner); + public class UnknownEntityError(Type entityType, Exception? inner = null) : FoxchatError($"Entity of type {entityType.Name} not found", inner); } public class ApiError(string message, HttpStatusCode? statusCode = null) : FoxchatError(message) @@ -23,10 +25,10 @@ public class ApiError(string message, HttpStatusCode? statusCode = null) : Foxch public class IncomingFederationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); public class OutgoingFederationError( - string message, Models.ApiError? innerError = null + string message, Models.Http.ApiError? innerError = null ) : ApiError(message, statusCode: HttpStatusCode.InternalServerError) { - public Models.ApiError? InnerError => innerError; + public Models.Http.ApiError? InnerError => innerError; } public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); diff --git a/Foxchat.Core/Models/ApiError.cs b/Foxchat.Core/Models/ApiError.cs deleted file mode 100644 index a9ac71d..0000000 --- a/Foxchat.Core/Models/ApiError.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Foxchat.Core.Models; - -public record ApiError(int Status, ErrorCode Code, string Message); - -public enum ErrorCode -{ - INTERNAL_SERVER_ERROR, - OBJECT_NOT_FOUND, - INVALID_SERVER, - INVALID_HEADER, - INVALID_DATE, - INVALID_SIGNATURE, - MISSING_SIGNATURE, - GUILD_NOT_FOUND, - UNAUTHORIZED, - INVALID_REST_EVENT, -} diff --git a/Foxchat.Core/Models/Http/ApiError.cs b/Foxchat.Core/Models/Http/ApiError.cs new file mode 100644 index 0000000..7d5df22 --- /dev/null +++ b/Foxchat.Core/Models/Http/ApiError.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Foxchat.Core.Models.Http; + +public record ApiError +{ + public required int Status { get; init; } + [JsonConverter(typeof(StringEnumConverter))] + public required ErrorCode Code { get; init; } + public required string Message { get; init; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public ApiError? OriginalError { get; init; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string[]? Scopes { get; init; } +} + +public enum ErrorCode +{ + InternalServerError, + Unauthorized, + Forbidden, + BadRequest, + OutgoingFederationError, + AuthenticationError, + // TODO: more specific API error codes + GenericApiError, +} diff --git a/Foxchat.Identity/Controllers/Oauth/AppsController.cs b/Foxchat.Identity/Controllers/Oauth/AppsController.cs index 113a3b7..4b6eed4 100644 --- a/Foxchat.Identity/Controllers/Oauth/AppsController.cs +++ b/Foxchat.Identity/Controllers/Oauth/AppsController.cs @@ -1,8 +1,9 @@ using Foxchat.Core; using Foxchat.Core.Models.Http; -using Foxchat.Identity.Authorization; +using Foxchat.Identity.Middleware; using Foxchat.Identity.Database; using Foxchat.Identity.Database.Models; +using Foxchat.Identity.Utils; using Microsoft.AspNetCore.Mvc; namespace Foxchat.Identity.Controllers.Oauth; @@ -29,9 +30,7 @@ public class AppsController(ILogger logger, IdentityContext db) : ControllerBase [HttpGet] public IActionResult GetSelfApp([FromQuery(Name = "with_secret")] bool withSecret) { - var token = HttpContext.GetToken(); - if (token is not { Account: null }) throw new ApiError.Forbidden("This endpoint requires a client token."); - var app = token.Application; + var app = HttpContext.GetApplicationOrThrow(); return Ok(new Apps.GetSelfResponse( app.Id, diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs new file mode 100644 index 0000000..7d867a1 --- /dev/null +++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs @@ -0,0 +1,22 @@ +using Foxchat.Identity.Middleware; +using Foxchat.Identity.Database; +using Foxchat.Identity.Utils; +using Microsoft.AspNetCore.Mvc; + +namespace Foxchat.Identity.Controllers.Oauth; + +[ApiController] +[Authenticate] +[Route("/_fox/ident/oauth/password")] +public class PasswordAuthController(ILogger logger, IdentityContext db) : ControllerBase +{ + [HttpPost("register")] + public async Task Register() + { + var app = HttpContext.GetApplicationOrThrow(); + + throw new NotImplementedException(); + } + + public record RegisterRequest(); +} \ No newline at end of file diff --git a/Foxchat.Identity/Controllers/Oauth/TokenController.cs b/Foxchat.Identity/Controllers/Oauth/TokenController.cs index 9a8806a..1f84414 100644 --- a/Foxchat.Identity/Controllers/Oauth/TokenController.cs +++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs @@ -16,7 +16,7 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) : var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret); var scopes = req.Scope.Split(' '); - if (app.Scopes.Except(scopes).Any()) + if (scopes.Except(app.Scopes).Any()) { throw new ApiError.BadRequest("Invalid or unauthorized scopes"); } diff --git a/Foxchat.Identity/Extensions/WebApplicationExtensions.cs b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs index 510a7c4..7597b43 100644 --- a/Foxchat.Identity/Extensions/WebApplicationExtensions.cs +++ b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs @@ -1,4 +1,4 @@ -using Foxchat.Identity.Authorization; +using Foxchat.Identity.Middleware; namespace Foxchat.Identity.Extensions; @@ -7,6 +7,7 @@ public static class WebApplicationExtensions public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) { return services + .AddScoped() .AddScoped() .AddScoped(); } @@ -14,6 +15,7 @@ public static class WebApplicationExtensions public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) { return app + .UseMiddleware() .UseMiddleware() .UseMiddleware(); } diff --git a/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs b/Foxchat.Identity/Middleware/AuthenticationMiddleware.cs similarity index 98% rename from Foxchat.Identity/Authorization/AuthenticationMiddleware.cs rename to Foxchat.Identity/Middleware/AuthenticationMiddleware.cs index 8b89399..663515d 100644 --- a/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs +++ b/Foxchat.Identity/Middleware/AuthenticationMiddleware.cs @@ -6,7 +6,7 @@ using Foxchat.Identity.Database.Models; using Microsoft.EntityFrameworkCore; using NodaTime; -namespace Foxchat.Identity.Authorization; +namespace Foxchat.Identity.Middleware; public class AuthenticationMiddleware( IdentityContext db, diff --git a/Foxchat.Identity/Authorization/AuthorizationMiddleware.cs b/Foxchat.Identity/Middleware/AuthorizationMiddleware.cs similarity index 94% rename from Foxchat.Identity/Authorization/AuthorizationMiddleware.cs rename to Foxchat.Identity/Middleware/AuthorizationMiddleware.cs index 2bb8203..46a2fe6 100644 --- a/Foxchat.Identity/Authorization/AuthorizationMiddleware.cs +++ b/Foxchat.Identity/Middleware/AuthorizationMiddleware.cs @@ -1,9 +1,8 @@ -using System.Net; using Foxchat.Core; using Foxchat.Identity.Database; using NodaTime; -namespace Foxchat.Identity.Authorization; +namespace Foxchat.Identity.Middleware; public class AuthorizationMiddleware( IdentityContext db, diff --git a/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs b/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs new file mode 100644 index 0000000..e56a660 --- /dev/null +++ b/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs @@ -0,0 +1,87 @@ + +using System.Net; +using Foxchat.Core; +using Foxchat.Core.Models.Http; +using Newtonsoft.Json; +using ApiError = Foxchat.Core.ApiError; +using HttpApiError = Foxchat.Core.Models.Http.ApiError; + +namespace Foxchat.Identity.Middleware; + +public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware +{ + public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) + { + try + { + await next(ctx); + } + catch (Exception e) + { + var type = e.TargetSite?.DeclaringType ?? typeof(ErrorHandlerMiddleware); + var typeName = e.TargetSite?.DeclaringType?.FullName ?? ""; + var logger = baseLogger.ForContext(type); + + if (ctx.Response.HasStarted) + { + logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName, ctx.Request.Path); + } + + if (e is ApiError ae) + { + ctx.Response.StatusCode = (int)ae.StatusCode; + ctx.Response.Headers.RequestId = ctx.TraceIdentifier; + ctx.Response.ContentType = "application/json; charset=utf-8"; + if (ae is ApiError.OutgoingFederationError ofe) + { + await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError + { + Status = (int)ofe.StatusCode, + Code = ErrorCode.OutgoingFederationError, + Message = ofe.Message, + OriginalError = ofe.InnerError + })); + return; + } + else if (ae is ApiError.Forbidden fe) + { + await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError + { + Status = (int)fe.StatusCode, + Code = ErrorCode.Forbidden, + Message = fe.Message, + Scopes = fe.Scopes.Length > 0 ? fe.Scopes : null + })); + return; + } + + await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError + { + Status = (int)ae.StatusCode, + Code = ErrorCode.GenericApiError, + Message = ae.Message, + })); + return; + } + + if (e is FoxchatError fce) + { + logger.Error(fce.Inner ?? fce, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path); + } + else + { + logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path); + } + + ctx.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + ctx.Response.Headers.RequestId = ctx.TraceIdentifier; + ctx.Response.ContentType = "application/json; charset=utf-8"; + await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError + { + Status = (int)HttpStatusCode.InternalServerError, + Code = ErrorCode.InternalServerError, + Message = "Internal server error", + })); + } + } +} \ No newline at end of file diff --git a/Foxchat.Identity/Program.cs b/Foxchat.Identity/Program.cs index ae53e12..782ca94 100644 --- a/Foxchat.Identity/Program.cs +++ b/Foxchat.Identity/Program.cs @@ -5,6 +5,7 @@ using Foxchat.Identity; using Foxchat.Identity.Database; using Foxchat.Identity.Services; using Foxchat.Identity.Extensions; +using Newtonsoft.Json; var builder = WebApplication.CreateBuilder(args); @@ -15,6 +16,15 @@ builder.AddSerilog(config.LogEventLevel); await BuildInfo.ReadBuildInfo(); Log.Information("Starting Foxchat.Identity {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash); +// Set the default converter to snake case as we use it in a couple places. +JsonConvert.DefaultSettings = () => new JsonSerializerSettings +{ + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy() + } +}; + builder.Services .AddControllers() .AddNewtonsoftJson(options => diff --git a/Foxchat.Identity/Utils/OauthUtils.cs b/Foxchat.Identity/Utils/OauthUtils.cs index 16256ce..ae37b1a 100644 --- a/Foxchat.Identity/Utils/OauthUtils.cs +++ b/Foxchat.Identity/Utils/OauthUtils.cs @@ -1,3 +1,7 @@ +using Foxchat.Core; +using Foxchat.Identity.Middleware; +using Foxchat.Identity.Database.Models; + namespace Foxchat.Identity.Utils; public static class OauthUtils @@ -20,4 +24,11 @@ public static class OauthUtils return false; } } + + public static Application GetApplicationOrThrow(this HttpContext context) + { + var token = context.GetToken(); + if (token is not { Account: null }) throw new ApiError.Forbidden("This endpoint requires a client token."); + return token.Application; + } } From 7959b64fe61d44c4944a89147a70ab5ed6943973 Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 20 May 2024 20:37:22 +0200 Subject: [PATCH 02/11] add basic account creation --- .../Oauth/PasswordAuthController.cs | 34 ++++++++++++++++--- .../Controllers/Oauth/TokenController.cs | 1 + Foxchat.Identity/Database/Models/Token.cs | 13 +++++++ .../Middleware/ErrorHandlerMiddleware.cs | 1 - 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs index 7d867a1..66df4bf 100644 --- a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs +++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs @@ -2,21 +2,47 @@ using Foxchat.Identity.Middleware; using Foxchat.Identity.Database; using Foxchat.Identity.Utils; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Identity; +using Foxchat.Identity.Database.Models; +using Foxchat.Core; +using System.Diagnostics; +using NodaTime; namespace Foxchat.Identity.Controllers.Oauth; [ApiController] [Authenticate] [Route("/_fox/ident/oauth/password")] -public class PasswordAuthController(ILogger logger, IdentityContext db) : ControllerBase +public class PasswordAuthController(ILogger logger, IdentityContext db, IClock clock) : ControllerBase { + private readonly PasswordHasher _passwordHasher = new(); + [HttpPost("register")] - public async Task Register() + 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 - throw new NotImplementedException(); + 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)); + + var acct = new Account + { + Username = req.Username, + Email = req.Email, + Role = Account.AccountRole.User + }; + + await db.AddAsync(acct); + var hashedPassword = await Task.Run(() => _passwordHasher.HashPassword(acct, req.Password)); + acct.Password = hashedPassword; + var (tokenStr, token) = Token.Create(acct, app, req.Scopes, clock.GetCurrentInstant() + Duration.FromDays(365)); + await db.AddAsync(token); + await db.SaveChangesAsync(); + + return Ok(new RegisterResponse(acct.Id, acct.Username, acct.Email, tokenStr)); } - public record RegisterRequest(); + public record RegisterRequest(string Username, string Password, string Email, string[] Scopes); + public record RegisterResponse(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 1f84414..5cff74e 100644 --- a/Foxchat.Identity/Controllers/Oauth/TokenController.cs +++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs @@ -26,6 +26,7 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) : case "client_credentials": return await HandleClientCredentialsAsync(app, scopes); case "authorization_code": + // TODO break; default: throw new ApiError.BadRequest("Unknown grant_type"); diff --git a/Foxchat.Identity/Database/Models/Token.cs b/Foxchat.Identity/Database/Models/Token.cs index 1ecbfb3..4a0c2d4 100644 --- a/Foxchat.Identity/Database/Models/Token.cs +++ b/Foxchat.Identity/Database/Models/Token.cs @@ -24,4 +24,17 @@ public class Token : BaseModel return (token, hash); } + + public static (string, Token) Create(Account? account, Application application, string[] scopes, Instant expires) + { + var (token, hash) = Generate(); + return (token, new() + { + Hash = hash, + Scopes = scopes, + Expires = expires, + Account = account, + Application = application, + }); + } } diff --git a/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs b/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs index e56a660..6b8d7e5 100644 --- a/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs +++ b/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs @@ -1,4 +1,3 @@ - using System.Net; using Foxchat.Core; using Foxchat.Core.Models.Http; From 656eec81d821a5cd2ba61bee2efe1189b436c485 Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 20 May 2024 21:59:30 +0200 Subject: [PATCH 03/11] add password-based login endpoint --- Foxchat.Core/FoxchatError.cs | 4 +-- .../Oauth/PasswordAuthController.cs | 34 +++++++++++++++++-- .../Controllers/Oauth/TokenController.cs | 9 +---- .../Middleware/AuthenticationMiddleware.cs | 7 ++++ Foxchat.Identity/Utils/OauthUtils.cs | 7 ---- 5 files changed, 40 insertions(+), 21 deletions(-) diff --git a/Foxchat.Core/FoxchatError.cs b/Foxchat.Core/FoxchatError.cs index 6c5324b..d2f5304 100644 --- a/Foxchat.Core/FoxchatError.cs +++ b/Foxchat.Core/FoxchatError.cs @@ -13,15 +13,13 @@ public class FoxchatError(string message, Exception? inner = null) : Exception(m public class ApiError(string message, HttpStatusCode? statusCode = null) : FoxchatError(message) { public readonly HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError; - public class Unauthorized(string message) : ApiError(message, statusCode: HttpStatusCode.Unauthorized); - public class Forbidden(string message, IEnumerable? scopes = null) : ApiError(message, statusCode: HttpStatusCode.Forbidden) { public readonly string[] Scopes = scopes?.ToArray() ?? []; } - public class BadRequest(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); + public class NotFound(string message) : ApiError(message, statusCode: HttpStatusCode.NotFound); public class IncomingFederationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); public class OutgoingFederationError( diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs index 66df4bf..927fadf 100644 --- a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs +++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs @@ -1,12 +1,12 @@ using Foxchat.Identity.Middleware; using Foxchat.Identity.Database; -using Foxchat.Identity.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Identity; using Foxchat.Identity.Database.Models; using Foxchat.Core; using System.Diagnostics; using NodaTime; +using Microsoft.EntityFrameworkCore; namespace Foxchat.Identity.Controllers.Oauth; @@ -36,13 +36,41 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c await db.AddAsync(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); await db.SaveChangesAsync(); - return Ok(new RegisterResponse(acct.Id, acct.Username, acct.Email, tokenStr)); + return Ok(new AuthResponse(acct.Id, acct.Username, acct.Email, tokenStr)); + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest req) + { + var app = HttpContext.GetApplicationOrThrow(); + 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)); + + 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"); + + 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); + 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 RegisterResponse(Ulid Id, string Username, string Email, string Token); + 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 5cff74e..829f9ec 100644 --- a/Foxchat.Identity/Controllers/Oauth/TokenController.cs +++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs @@ -39,14 +39,7 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) : { // TODO: make this configurable var expiry = clock.GetCurrentInstant() + Duration.FromDays(365); - var (token, hash) = Token.Generate(); - var tokenObj = new Token - { - Hash = hash, - Scopes = scopes, - Expires = expiry, - ApplicationId = app.Id - }; + var (token, tokenObj) = Token.Create(null, app, scopes, expiry); await db.AddAsync(tokenObj); await db.SaveChangesAsync(); diff --git a/Foxchat.Identity/Middleware/AuthenticationMiddleware.cs b/Foxchat.Identity/Middleware/AuthenticationMiddleware.cs index 663515d..224e485 100644 --- a/Foxchat.Identity/Middleware/AuthenticationMiddleware.cs +++ b/Foxchat.Identity/Middleware/AuthenticationMiddleware.cs @@ -71,6 +71,13 @@ public static class HttpContextExtensions return token as Token; return null; } + + public static Application GetApplicationOrThrow(this HttpContext context) + { + var token = context.GetToken(); + if (token is not { Account: null }) throw new ApiError.Forbidden("This endpoint requires a client token."); + return token.Application; + } } [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] diff --git a/Foxchat.Identity/Utils/OauthUtils.cs b/Foxchat.Identity/Utils/OauthUtils.cs index ae37b1a..26188bb 100644 --- a/Foxchat.Identity/Utils/OauthUtils.cs +++ b/Foxchat.Identity/Utils/OauthUtils.cs @@ -24,11 +24,4 @@ public static class OauthUtils return false; } } - - public static Application GetApplicationOrThrow(this HttpContext context) - { - var token = context.GetToken(); - if (token is not { Account: null }) throw new ApiError.Forbidden("This endpoint requires a client token."); - return token.Application; - } } From 6f6e19bbb50037f499655d2f05c6d711bd8411f6 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 21 May 2024 16:41:01 +0200 Subject: [PATCH 04/11] chat: add database types and auth middleware --- .idea/.idea.Foxchat/.idea/.gitignore | 13 + .idea/.idea.Foxchat/.idea/.name | 1 + .idea/.idea.Foxchat/.idea/encodings.xml | 4 + .idea/.idea.Foxchat/.idea/indexLayout.xml | 8 + .idea/.idea.Foxchat/.idea/sqldialects.xml | 7 + .idea/.idea.Foxchat/.idea/vcs.xml | 6 + Foxchat.Chat/Database/BaseModel.cs | 6 + Foxchat.Chat/Database/ChatContext.cs | 31 +- Foxchat.Chat/Database/Models/Channel.cs | 9 + Foxchat.Chat/Database/Models/Guild.cs | 11 + .../Database/Models/IdentityInstance.cs | 17 + Foxchat.Chat/Database/Models/Message.cs | 15 + Foxchat.Chat/Database/Models/User.cs | 14 + .../Extensions/WebApplicationExtensions.cs | 18 + .../Middleware/AuthenticationMiddleware.cs | 106 ++++++ .../20240521132416_Init.Designer.cs | 325 ++++++++++++++++++ .../Migrations/20240521132416_Init.cs | 228 ++++++++++++ .../Migrations/ChatContextModelSnapshot.cs | 322 +++++++++++++++++ Foxchat.Chat/Program.cs | 12 +- Foxchat.Core/{ => Database}/UlidConverter.cs | 0 .../Federation/RequestSigningService.cs | 11 +- Foxchat.Core/Federation/SignatureData.cs | 11 +- Foxchat.Identity/Database/IdentityContext.cs | 3 +- Foxchat.Identity/Program.cs | 2 +- 24 files changed, 1165 insertions(+), 15 deletions(-) create mode 100644 .idea/.idea.Foxchat/.idea/.gitignore create mode 100644 .idea/.idea.Foxchat/.idea/.name create mode 100644 .idea/.idea.Foxchat/.idea/encodings.xml create mode 100644 .idea/.idea.Foxchat/.idea/indexLayout.xml create mode 100644 .idea/.idea.Foxchat/.idea/sqldialects.xml create mode 100644 .idea/.idea.Foxchat/.idea/vcs.xml create mode 100644 Foxchat.Chat/Database/BaseModel.cs create mode 100644 Foxchat.Chat/Database/Models/Channel.cs create mode 100644 Foxchat.Chat/Database/Models/Guild.cs create mode 100644 Foxchat.Chat/Database/Models/IdentityInstance.cs create mode 100644 Foxchat.Chat/Database/Models/Message.cs create mode 100644 Foxchat.Chat/Database/Models/User.cs create mode 100644 Foxchat.Chat/Extensions/WebApplicationExtensions.cs create mode 100644 Foxchat.Chat/Middleware/AuthenticationMiddleware.cs create mode 100644 Foxchat.Chat/Migrations/20240521132416_Init.Designer.cs create mode 100644 Foxchat.Chat/Migrations/20240521132416_Init.cs create mode 100644 Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs rename Foxchat.Core/{ => Database}/UlidConverter.cs (100%) diff --git a/.idea/.idea.Foxchat/.idea/.gitignore b/.idea/.idea.Foxchat/.idea/.gitignore new file mode 100644 index 0000000..cb00b47 --- /dev/null +++ b/.idea/.idea.Foxchat/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/.idea.Foxchat.iml +/contentModel.xml +/modules.xml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.Foxchat/.idea/.name b/.idea/.idea.Foxchat/.idea/.name new file mode 100644 index 0000000..4dfb495 --- /dev/null +++ b/.idea/.idea.Foxchat/.idea/.name @@ -0,0 +1 @@ +Foxchat \ No newline at end of file diff --git a/.idea/.idea.Foxchat/.idea/encodings.xml b/.idea/.idea.Foxchat/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.Foxchat/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.Foxchat/.idea/indexLayout.xml b/.idea/.idea.Foxchat/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.Foxchat/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.Foxchat/.idea/sqldialects.xml b/.idea/.idea.Foxchat/.idea/sqldialects.xml new file mode 100644 index 0000000..387b18e --- /dev/null +++ b/.idea/.idea.Foxchat/.idea/sqldialects.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.Foxchat/.idea/vcs.xml b/.idea/.idea.Foxchat/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/.idea.Foxchat/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Foxchat.Chat/Database/BaseModel.cs b/Foxchat.Chat/Database/BaseModel.cs new file mode 100644 index 0000000..9d90e4d --- /dev/null +++ b/Foxchat.Chat/Database/BaseModel.cs @@ -0,0 +1,6 @@ +namespace Foxchat.Chat.Database; + +public abstract class BaseModel +{ + public Ulid Id { get; init; } = Ulid.NewUlid(); +} diff --git a/Foxchat.Chat/Database/ChatContext.cs b/Foxchat.Chat/Database/ChatContext.cs index 09e0180..efbe8a1 100644 --- a/Foxchat.Chat/Database/ChatContext.cs +++ b/Foxchat.Chat/Database/ChatContext.cs @@ -1,3 +1,4 @@ +using Foxchat.Chat.Database.Models; using Foxchat.Core; using Foxchat.Core.Database; using Microsoft.EntityFrameworkCore; @@ -11,6 +12,11 @@ public class ChatContext : IDatabaseContext private readonly NpgsqlDataSource _dataSource; public override DbSet Instance { get; set; } + public DbSet IdentityInstances { get; set; } + public DbSet Users { get; set; } + public DbSet Guilds { get; set; } + public DbSet Channels { get; set; } + public DbSet Messages { get; set; } public ChatContext(InstanceConfig config) { @@ -26,9 +32,9 @@ public class ChatContext : IDatabaseContext } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder - .UseNpgsql(_dataSource, o => o.UseNodaTime()) - .UseSnakeCaseNamingConvention(); + => optionsBuilder + .UseNpgsql(_dataSource, o => o.UseNodaTime()) + .UseSnakeCaseNamingConvention(); protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { @@ -38,20 +44,35 @@ public class ChatContext : IDatabaseContext protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity().HasIndex(i => i.Domain).IsUnique(); + + modelBuilder.Entity().HasIndex(u => new { u.RemoteUserId, u.InstanceId }).IsUnique(); + modelBuilder.Entity().HasIndex(u => new { u.Username, u.InstanceId }).IsUnique(); + + modelBuilder.Entity() + .HasOne(e => e.Owner) + .WithMany(e => e.OwnedGuilds) + .HasForeignKey(e => e.OwnerId) + .IsRequired(); + + modelBuilder.Entity() + .HasMany(e => e.Guilds) + .WithMany(e => e.Users); } } +// ReSharper disable once UnusedType.Global public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory { public ChatContext CreateDbContext(string[] args) { // Read the configuration file var config = new ConfigurationBuilder() - .AddConfiguration("identity.ini") + .AddConfiguration("chat.ini") .Build() // Get the configuration as our config class .Get() ?? new(); return new ChatContext(config); } -} +} \ No newline at end of file diff --git a/Foxchat.Chat/Database/Models/Channel.cs b/Foxchat.Chat/Database/Models/Channel.cs new file mode 100644 index 0000000..90a2530 --- /dev/null +++ b/Foxchat.Chat/Database/Models/Channel.cs @@ -0,0 +1,9 @@ +namespace Foxchat.Chat.Database.Models; + +public class Channel : BaseModel +{ + public Ulid GuildId { get; init; } + public Guild Guild { get; init; } = null!; + public string Name { get; set; } = null!; + public string? Topic { get; set; } +} \ No newline at end of file diff --git a/Foxchat.Chat/Database/Models/Guild.cs b/Foxchat.Chat/Database/Models/Guild.cs new file mode 100644 index 0000000..022b48b --- /dev/null +++ b/Foxchat.Chat/Database/Models/Guild.cs @@ -0,0 +1,11 @@ +namespace Foxchat.Chat.Database.Models; + +public class Guild : BaseModel +{ + public string Name { get; set; } = null!; + public Ulid OwnerId { get; set; } + public User Owner { get; set; } = null!; + + public List Users { get; } = []; + public List Channels { get; } = []; +} \ No newline at end of file diff --git a/Foxchat.Chat/Database/Models/IdentityInstance.cs b/Foxchat.Chat/Database/Models/IdentityInstance.cs new file mode 100644 index 0000000..ba978c3 --- /dev/null +++ b/Foxchat.Chat/Database/Models/IdentityInstance.cs @@ -0,0 +1,17 @@ +namespace Foxchat.Chat.Database.Models; + +public class IdentityInstance : BaseModel +{ + public string Domain { get; init; } = null!; + public string BaseUrl { get; init; } = null!; + public string PublicKey { get; init; } = null!; + + public InstanceStatus Status { get; set; } = InstanceStatus.Active; + public string? Reason { get; set; } + + public enum InstanceStatus + { + Active, + Suspended, + } +} diff --git a/Foxchat.Chat/Database/Models/Message.cs b/Foxchat.Chat/Database/Models/Message.cs new file mode 100644 index 0000000..4fc90e8 --- /dev/null +++ b/Foxchat.Chat/Database/Models/Message.cs @@ -0,0 +1,15 @@ +using NodaTime; + +namespace Foxchat.Chat.Database.Models; + +public class Message : BaseModel +{ + public Ulid ChannelId { get; init; } + public Channel Channel { get; init; } = null!; + public Ulid AuthorId { get; init; } + public User Author { get; init; } = null!; + + public string? Content { get; set; } + + public Instant? UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/Foxchat.Chat/Database/Models/User.cs b/Foxchat.Chat/Database/Models/User.cs new file mode 100644 index 0000000..18a7054 --- /dev/null +++ b/Foxchat.Chat/Database/Models/User.cs @@ -0,0 +1,14 @@ +namespace Foxchat.Chat.Database.Models; + +public class User : BaseModel +{ + public Ulid InstanceId { get; init; } + public IdentityInstance Instance { get; init; } = null!; + public string RemoteUserId { get; init; } = null!; + public string Username { get; init; } = null!; + + public string? Avatar { get; set; } + + public List Guilds { get; } = []; + public List OwnedGuilds { get; } = []; +} \ No newline at end of file diff --git a/Foxchat.Chat/Extensions/WebApplicationExtensions.cs b/Foxchat.Chat/Extensions/WebApplicationExtensions.cs new file mode 100644 index 0000000..4900ba9 --- /dev/null +++ b/Foxchat.Chat/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,18 @@ +using Foxchat.Chat.Middleware; + +namespace Foxchat.Chat.Extensions; + +public static class WebApplicationExtensions +{ + public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) + { + return services + .AddScoped(); + } + + public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) + { + return app + .UseMiddleware(); + } +} \ No newline at end of file diff --git a/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs b/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs new file mode 100644 index 0000000..13955bd --- /dev/null +++ b/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs @@ -0,0 +1,106 @@ +using Foxchat.Chat.Database; +using Foxchat.Chat.Database.Models; +using Foxchat.Core; +using Foxchat.Core.Federation; +using Microsoft.EntityFrameworkCore; + +namespace Foxchat.Chat.Middleware; + +public class AuthenticationMiddleware(ILogger logger, ChatContext db, RequestSigningService requestSigningService) + : IMiddleware +{ + public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) + { + var endpoint = ctx.GetEndpoint(); + // Endpoints require server authentication by default, unless they have the [Unauthenticated] attribute. + var metadata = endpoint?.Metadata.GetMetadata(); + if (metadata != null) + { + await next(ctx); + return; + } + + if (!ExtractRequestData(ctx, out var signature, out var domain, out var signatureData)) + throw new ApiError.IncomingFederationError("This endpoint requires signed requests."); + + var instance = await GetInstanceAsync(domain); + + if (!requestSigningService.VerifySignature(instance.PublicKey, signature, signatureData)) + throw new ApiError.IncomingFederationError("Signature is not valid."); + + ctx.SetSignature(instance, signatureData); + + await next(ctx); + } + + private async Task GetInstanceAsync(string domain) + { + 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)] +public class UnauthenticatedAttribute : Attribute; + +public static class HttpContextExtensions +{ + private const string Key = "instance"; + + public static void SetSignature(this HttpContext ctx, IdentityInstance instance, SignatureData data) + { + ctx.Items.Add(Key, (instance, data)); + } + + public static (IdentityInstance?, SignatureData?) GetSignature(this HttpContext ctx) + { + try + { + var obj = ctx.GetSignatureOrThrow(); + return (obj.Item1, obj.Item2); + } + catch + { + return (null, null); + } + } + + public static (IdentityInstance, SignatureData) GetSignatureOrThrow(this HttpContext ctx) + { + if (!ctx.Items.TryGetValue(Key, out var obj)) + throw new ApiError.AuthenticationError("No instance in HttpContext"); + + return ((IdentityInstance, SignatureData))obj!; + } +} \ No newline at end of file diff --git a/Foxchat.Chat/Migrations/20240521132416_Init.Designer.cs b/Foxchat.Chat/Migrations/20240521132416_Init.Designer.cs new file mode 100644 index 0000000..ff599f3 --- /dev/null +++ b/Foxchat.Chat/Migrations/20240521132416_Init.Designer.cs @@ -0,0 +1,325 @@ +// +using System; +using Foxchat.Chat.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxchat.Chat.Migrations +{ + [DbContext(typeof(ChatContext))] + [Migration("20240521132416_Init")] + partial class Init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GuildId") + .HasColumnType("uuid") + .HasColumnName("guild_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Topic") + .HasColumnType("text") + .HasColumnName("topic"); + + b.HasKey("Id") + .HasName("pk_channels"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_channels_guild_id"); + + b.ToTable("channels", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("pk_guilds"); + + b.HasIndex("OwnerId") + .HasDatabaseName("ix_guilds_owner_id"); + + b.ToTable("guilds", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.IdentityInstance", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("base_url"); + + b.Property("Domain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("public_key"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_identity_instances"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_identity_instances_domain"); + + b.ToTable("identity_instances", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ChannelId") + .HasColumnType("uuid") + .HasColumnName("channel_id"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_messages"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_messages_author_id"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_messages_channel_id"); + + b.ToTable("messages", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasColumnName("instance_id"); + + b.Property("RemoteUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("remote_user_id"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("InstanceId") + .HasDatabaseName("ix_users_instance_id"); + + b.HasIndex("RemoteUserId", "InstanceId") + .IsUnique() + .HasDatabaseName("ix_users_remote_user_id_instance_id"); + + b.HasIndex("Username", "InstanceId") + .IsUnique() + .HasDatabaseName("ix_users_username_instance_id"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Foxchat.Core.Database.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("private_key"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("public_key"); + + b.HasKey("Id") + .HasName("pk_instance"); + + b.ToTable("instance", (string)null); + }); + + modelBuilder.Entity("GuildUser", b => + { + b.Property("GuildsId") + .HasColumnType("uuid") + .HasColumnName("guilds_id"); + + b.Property("UsersId") + .HasColumnType("uuid") + .HasColumnName("users_id"); + + b.HasKey("GuildsId", "UsersId") + .HasName("pk_guild_user"); + + b.HasIndex("UsersId") + .HasDatabaseName("ix_guild_user_users_id"); + + b.ToTable("guild_user", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b => + { + b.HasOne("Foxchat.Chat.Database.Models.Guild", "Guild") + .WithMany("Channels") + .HasForeignKey("GuildId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_channels_guilds_guild_id"); + + b.Navigation("Guild"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.HasOne("Foxchat.Chat.Database.Models.User", "Owner") + .WithMany("OwnedGuilds") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guilds_users_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b => + { + b.HasOne("Foxchat.Chat.Database.Models.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_messages_users_author_id"); + + b.HasOne("Foxchat.Chat.Database.Models.Channel", "Channel") + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_messages_channels_channel_id"); + + b.Navigation("Author"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.HasOne("Foxchat.Chat.Database.Models.IdentityInstance", "Instance") + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_users_identity_instances_instance_id"); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("GuildUser", b => + { + b.HasOne("Foxchat.Chat.Database.Models.Guild", null) + .WithMany() + .HasForeignKey("GuildsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_user_guilds_guilds_id"); + + b.HasOne("Foxchat.Chat.Database.Models.User", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_user_users_users_id"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.Navigation("OwnedGuilds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Foxchat.Chat/Migrations/20240521132416_Init.cs b/Foxchat.Chat/Migrations/20240521132416_Init.cs new file mode 100644 index 0000000..efb5e07 --- /dev/null +++ b/Foxchat.Chat/Migrations/20240521132416_Init.cs @@ -0,0 +1,228 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxchat.Chat.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "identity_instances", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + domain = table.Column(type: "text", nullable: false), + base_url = table.Column(type: "text", nullable: false), + public_key = table.Column(type: "text", nullable: false), + status = table.Column(type: "integer", nullable: false), + reason = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_identity_instances", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "instance", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + public_key = table.Column(type: "text", nullable: false), + private_key = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_instance", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + instance_id = table.Column(type: "uuid", nullable: false), + remote_user_id = table.Column(type: "text", nullable: false), + username = table.Column(type: "text", nullable: false), + avatar = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_users", x => x.id); + table.ForeignKey( + name: "fk_users_identity_instances_instance_id", + column: x => x.instance_id, + principalTable: "identity_instances", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "guilds", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "text", nullable: false), + owner_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_guilds", x => x.id); + table.ForeignKey( + name: "fk_guilds_users_owner_id", + column: x => x.owner_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "channels", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + guild_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "text", nullable: false), + topic = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_channels", x => x.id); + table.ForeignKey( + name: "fk_channels_guilds_guild_id", + column: x => x.guild_id, + principalTable: "guilds", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "guild_user", + columns: table => new + { + guilds_id = table.Column(type: "uuid", nullable: false), + users_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_guild_user", x => new { x.guilds_id, x.users_id }); + table.ForeignKey( + name: "fk_guild_user_guilds_guilds_id", + column: x => x.guilds_id, + principalTable: "guilds", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_guild_user_users_users_id", + column: x => x.users_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "messages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + channel_id = table.Column(type: "uuid", nullable: false), + author_id = table.Column(type: "uuid", nullable: false), + content = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_messages", x => x.id); + table.ForeignKey( + name: "fk_messages_channels_channel_id", + column: x => x.channel_id, + principalTable: "channels", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_messages_users_author_id", + column: x => x.author_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_channels_guild_id", + table: "channels", + column: "guild_id"); + + migrationBuilder.CreateIndex( + name: "ix_guild_user_users_id", + table: "guild_user", + column: "users_id"); + + migrationBuilder.CreateIndex( + name: "ix_guilds_owner_id", + table: "guilds", + column: "owner_id"); + + migrationBuilder.CreateIndex( + name: "ix_identity_instances_domain", + table: "identity_instances", + column: "domain", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_messages_author_id", + table: "messages", + column: "author_id"); + + migrationBuilder.CreateIndex( + name: "ix_messages_channel_id", + table: "messages", + column: "channel_id"); + + migrationBuilder.CreateIndex( + name: "ix_users_instance_id", + table: "users", + column: "instance_id"); + + migrationBuilder.CreateIndex( + name: "ix_users_remote_user_id_instance_id", + table: "users", + columns: new[] { "remote_user_id", "instance_id" }, + unique: true); + + // EF Core doesn't support creating indexes on arbitrary expressions, so we have to create it manually. + migrationBuilder.Sql("CREATE UNIQUE INDEX ix_users_username_instance_id ON users (lower(username), instance_id)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "guild_user"); + + migrationBuilder.DropTable( + name: "instance"); + + migrationBuilder.DropTable( + name: "messages"); + + migrationBuilder.DropTable( + name: "channels"); + + migrationBuilder.DropTable( + name: "guilds"); + + migrationBuilder.DropTable( + name: "users"); + + migrationBuilder.DropTable( + name: "identity_instances"); + } + } +} diff --git a/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs new file mode 100644 index 0000000..f560113 --- /dev/null +++ b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs @@ -0,0 +1,322 @@ +// +using System; +using Foxchat.Chat.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxchat.Chat.Migrations +{ + [DbContext(typeof(ChatContext))] + partial class ChatContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GuildId") + .HasColumnType("uuid") + .HasColumnName("guild_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Topic") + .HasColumnType("text") + .HasColumnName("topic"); + + b.HasKey("Id") + .HasName("pk_channels"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_channels_guild_id"); + + b.ToTable("channels", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("pk_guilds"); + + b.HasIndex("OwnerId") + .HasDatabaseName("ix_guilds_owner_id"); + + b.ToTable("guilds", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.IdentityInstance", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("base_url"); + + b.Property("Domain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("public_key"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_identity_instances"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_identity_instances_domain"); + + b.ToTable("identity_instances", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ChannelId") + .HasColumnType("uuid") + .HasColumnName("channel_id"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_messages"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_messages_author_id"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_messages_channel_id"); + + b.ToTable("messages", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasColumnName("instance_id"); + + b.Property("RemoteUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("remote_user_id"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("InstanceId") + .HasDatabaseName("ix_users_instance_id"); + + b.HasIndex("RemoteUserId", "InstanceId") + .IsUnique() + .HasDatabaseName("ix_users_remote_user_id_instance_id"); + + b.HasIndex("Username", "InstanceId") + .IsUnique() + .HasDatabaseName("ix_users_username_instance_id"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Foxchat.Core.Database.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("private_key"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("public_key"); + + b.HasKey("Id") + .HasName("pk_instance"); + + b.ToTable("instance", (string)null); + }); + + modelBuilder.Entity("GuildUser", b => + { + b.Property("GuildsId") + .HasColumnType("uuid") + .HasColumnName("guilds_id"); + + b.Property("UsersId") + .HasColumnType("uuid") + .HasColumnName("users_id"); + + b.HasKey("GuildsId", "UsersId") + .HasName("pk_guild_user"); + + b.HasIndex("UsersId") + .HasDatabaseName("ix_guild_user_users_id"); + + b.ToTable("guild_user", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b => + { + b.HasOne("Foxchat.Chat.Database.Models.Guild", "Guild") + .WithMany("Channels") + .HasForeignKey("GuildId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_channels_guilds_guild_id"); + + b.Navigation("Guild"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.HasOne("Foxchat.Chat.Database.Models.User", "Owner") + .WithMany("OwnedGuilds") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guilds_users_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b => + { + b.HasOne("Foxchat.Chat.Database.Models.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_messages_users_author_id"); + + b.HasOne("Foxchat.Chat.Database.Models.Channel", "Channel") + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_messages_channels_channel_id"); + + b.Navigation("Author"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.HasOne("Foxchat.Chat.Database.Models.IdentityInstance", "Instance") + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_users_identity_instances_instance_id"); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("GuildUser", b => + { + b.HasOne("Foxchat.Chat.Database.Models.Guild", null) + .WithMany() + .HasForeignKey("GuildsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_user_guilds_guilds_id"); + + b.HasOne("Foxchat.Chat.Database.Models.User", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_user_users_users_id"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.Navigation("OwnedGuilds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Foxchat.Chat/Program.cs b/Foxchat.Chat/Program.cs index 68e4115..11ab291 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 Newtonsoft.Json; var builder = WebApplication.CreateBuilder(args); @@ -13,6 +14,15 @@ builder.AddSerilog(config.LogEventLevel); await BuildInfo.ReadBuildInfo(); Log.Information("Starting Foxchat.Chat {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash); +// Set the default converter to snake case as we use it in a couple places. +JsonConvert.DefaultSettings = () => new JsonSerializerSettings +{ + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy() + } +}; + builder.Services .AddControllers() .AddNewtonsoftJson(options => @@ -38,7 +48,7 @@ app.UseAuthorization(); app.MapControllers(); using (var scope = app.Services.CreateScope()) -using (var context = scope.ServiceProvider.GetRequiredService()) +await using (var context = scope.ServiceProvider.GetRequiredService()) { Log.Information("Initializing instance keypair..."); if (await context.InitializeInstanceAsync()) diff --git a/Foxchat.Core/UlidConverter.cs b/Foxchat.Core/Database/UlidConverter.cs similarity index 100% rename from Foxchat.Core/UlidConverter.cs rename to Foxchat.Core/Database/UlidConverter.cs diff --git a/Foxchat.Core/Federation/RequestSigningService.cs b/Foxchat.Core/Federation/RequestSigningService.cs index 07b0536..54ec6bf 100644 --- a/Foxchat.Core/Federation/RequestSigningService.cs +++ b/Foxchat.Core/Federation/RequestSigningService.cs @@ -31,23 +31,22 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba } public bool VerifySignature( - string publicKey, string encodedSignature, string dateHeader, string host, string requestPath, int? contentLength, string? userId) + string publicKey, string encodedSignature, SignatureData data) { var rsa = RSA.Create(); rsa.ImportFromPem(publicKey); var now = _clock.GetCurrentInstant(); - var time = ParseTime(dateHeader); - if ((now + Duration.FromMinutes(1)) < time) + if ((now + Duration.FromMinutes(1)) < data.Time) { throw new ApiError.IncomingFederationError("Request was made in the future"); } - else if ((now - Duration.FromMinutes(1)) > time) + else if ((now - Duration.FromMinutes(1)) > data.Time) { throw new ApiError.IncomingFederationError("Request was made too long ago"); } - var plaintext = GeneratePlaintext(new SignatureData(time, host, requestPath, contentLength, userId)); + var plaintext = GeneratePlaintext(data); var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); var hash = SHA256.HashData(plaintextBytes); @@ -73,5 +72,5 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba private static readonly InstantPattern _pattern = InstantPattern.Create("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.GetCultureInfo("en-US")); private static string FormatTime(Instant time) => _pattern.Format(time); - private static Instant ParseTime(string header) => _pattern.Parse(header).GetValueOrThrow(); + public static Instant ParseTime(string header) => _pattern.Parse(header).GetValueOrThrow(); } diff --git a/Foxchat.Core/Federation/SignatureData.cs b/Foxchat.Core/Federation/SignatureData.cs index 54878f2..025ed19 100644 --- a/Foxchat.Core/Federation/SignatureData.cs +++ b/Foxchat.Core/Federation/SignatureData.cs @@ -8,4 +8,13 @@ public record SignatureData( string RequestPath, int? ContentLength, string? UserId -); +) +{ + public static readonly SignatureData Empty = new( + Instant.MinValue, + string.Empty, + string.Empty, + null, + null + ); +} \ No newline at end of file diff --git a/Foxchat.Identity/Database/IdentityContext.cs b/Foxchat.Identity/Database/IdentityContext.cs index b8c7bca..e983fac 100644 --- a/Foxchat.Identity/Database/IdentityContext.cs +++ b/Foxchat.Identity/Database/IdentityContext.cs @@ -11,9 +11,9 @@ public class IdentityContext : IDatabaseContext { private readonly NpgsqlDataSource _dataSource; + public override DbSet Instance { get; set; } public DbSet Accounts { get; set; } public DbSet ChatInstances { get; set; } - public override DbSet Instance { get; set; } public DbSet Applications { get; set; } public DbSet Tokens { get; set; } public DbSet GuildAccounts { get; set; } @@ -55,6 +55,7 @@ public class IdentityContext : IDatabaseContext } } +// ReSharper disable once UnusedType.Global public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory { public IdentityContext CreateDbContext(string[] args) diff --git a/Foxchat.Identity/Program.cs b/Foxchat.Identity/Program.cs index 782ca94..8e0c47e 100644 --- a/Foxchat.Identity/Program.cs +++ b/Foxchat.Identity/Program.cs @@ -51,7 +51,7 @@ app.UseCustomMiddleware(); app.MapControllers(); using (var scope = app.Services.CreateScope()) -using (var context = scope.ServiceProvider.GetRequiredService()) +await using (var context = scope.ServiceProvider.GetRequiredService()) { Log.Information("Initializing instance keypair..."); if (await context.InitializeInstanceAsync()) From 7b4cbd4fb7f13746fff4aadc230af8b2f0c44a2e Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 21 May 2024 17:45:35 +0200 Subject: [PATCH 05/11] chat: add hello controller --- Foxchat.Chat/Controllers/HelloController.cs | 45 +++++++++++++++++++ .../Middleware/AuthenticationMiddleware.cs | 34 +------------- Foxchat.Chat/Program.cs | 5 ++- Foxchat.Core/Database/IDatabaseContext.cs | 4 +- .../Extensions/HttpContextExtensions.cs | 38 ++++++++++++++++ .../ServiceCollectionExtensions.cs | 0 Foxchat.Core/Models/Http/Hello.cs | 2 +- .../Controllers/NodeController.cs | 10 +++-- .../Controllers/Oauth/AppsController.cs | 2 +- .../Oauth/PasswordAuthController.cs | 23 ++++++---- .../Controllers/Oauth/TokenController.cs | 2 +- .../Services/ChatInstanceResolverService.cs | 2 +- 12 files changed, 114 insertions(+), 53 deletions(-) create mode 100644 Foxchat.Chat/Controllers/HelloController.cs create mode 100644 Foxchat.Core/Extensions/HttpContextExtensions.cs rename Foxchat.Core/{ => Extensions}/ServiceCollectionExtensions.cs (100%) 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; From 727f2f6ba27f10d5d25ffc191bf82f65de6fc7a0 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 21 May 2024 20:14:52 +0200 Subject: [PATCH 06/11] chat: add initial GuildsController --- .editorconfig | 3 ++ .gitignore | 44 +++++++++++++++++ .../Controllers/Api/GuildsController.cs | 47 +++++++++++++++++++ Foxchat.Chat/Controllers/HelloController.cs | 4 +- Foxchat.Chat/Database/Models/User.cs | 4 ++ .../Extensions/WebApplicationExtensions.cs | 14 +++++- ...e.cs => ServerAuthenticationMiddleware.cs} | 9 ++-- Foxchat.Chat/Program.cs | 1 + Foxchat.Chat/Services/UserResolverService.cs | 35 ++++++++++++++ .../Extensions/HttpContextExtensions.cs | 10 ++-- .../Federation/RequestSigningService.cs | 21 +++++---- .../Middleware/ErrorHandlerMiddleware.cs | 8 ++-- Foxchat.Core/Models/Channels.cs | 8 ++++ Foxchat.Core/Models/Guilds.cs | 14 ++++++ .../Models/Http/{Apps.cs => AppsApi.cs} | 2 +- Foxchat.Core/Models/Http/GuildsApi.cs | 6 +++ Foxchat.Core/Models/Users.cs | 8 ++++ .../Controllers/Oauth/AppsController.cs | 8 ++-- .../Oauth/PasswordAuthController.cs | 2 +- .../Controllers/UsersController.cs | 21 +++++++++ .../Extensions/WebApplicationExtensions.cs | 9 ++-- ...e.cs => ClientAuthenticationMiddleware.cs} | 6 +-- ...re.cs => ClientAuthorizationMiddleware.cs} | 2 +- 23 files changed, 248 insertions(+), 38 deletions(-) create mode 100644 Foxchat.Chat/Controllers/Api/GuildsController.cs rename Foxchat.Chat/Middleware/{AuthenticationMiddleware.cs => ServerAuthenticationMiddleware.cs} (84%) create mode 100644 Foxchat.Chat/Services/UserResolverService.cs rename {Foxchat.Identity => Foxchat.Core}/Middleware/ErrorHandlerMiddleware.cs (95%) create mode 100644 Foxchat.Core/Models/Channels.cs create mode 100644 Foxchat.Core/Models/Guilds.cs rename Foxchat.Core/Models/Http/{Apps.cs => AppsApi.cs} (93%) create mode 100644 Foxchat.Core/Models/Http/GuildsApi.cs create mode 100644 Foxchat.Core/Models/Users.cs create mode 100644 Foxchat.Identity/Controllers/UsersController.cs rename Foxchat.Identity/Middleware/{AuthenticationMiddleware.cs => ClientAuthenticationMiddleware.cs} (92%) rename Foxchat.Identity/Middleware/{AuthorizationMiddleware.cs => ClientAuthorizationMiddleware.cs} (96%) diff --git a/.editorconfig b/.editorconfig index 9fec5aa..00c5659 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,3 +2,6 @@ # CS9113: Parameter is unread. dotnet_diagnostic.CS9113.severity = silent + +# EntityFramework.ModelValidation.UnlimitedStringLength +resharper_entity_framework_model_validation_unlimited_string_length_highlighting=none \ No newline at end of file diff --git a/.gitignore b/.gitignore index cd1b080..d01c4a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,47 @@ bin/ obj/ .version + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf +.idea/**/discord.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-*/ + +# File-based project format +*.iws + +# Editor-based Rest Client +.idea/httpRequests + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets diff --git a/Foxchat.Chat/Controllers/Api/GuildsController.cs b/Foxchat.Chat/Controllers/Api/GuildsController.cs new file mode 100644 index 0000000..ab99a6d --- /dev/null +++ b/Foxchat.Chat/Controllers/Api/GuildsController.cs @@ -0,0 +1,47 @@ +using Foxchat.Chat.Database; +using Foxchat.Chat.Database.Models; +using Foxchat.Chat.Middleware; +using Foxchat.Chat.Services; +using Foxchat.Core.Models; +using Foxchat.Core.Models.Http; +using Microsoft.AspNetCore.Mvc; +using ApiError = Foxchat.Core.ApiError; + +namespace Foxchat.Chat.Controllers.Api; + +[ApiController] +[Route("/_fox/chat/guilds")] +public class GuildsController(ILogger logger, ChatContext db, UserResolverService userResolverService) : ControllerBase +{ + [HttpPost] + public async Task CreateGuild([FromBody] GuildsApi.CreateGuildRequest req) + { + var (instance, sig) = HttpContext.GetSignatureOrThrow(); + if (sig.UserId == null) throw new ApiError.IncomingFederationError("This endpoint requires a user ID."); + + var user = await userResolverService.ResolveUserAsync(instance, sig.UserId); + + var guild = new Guild + { + Name = req.Name, + Owner = user, + }; + db.Add(guild); + guild.Users.Add(user); + var defaultChannel = new Channel + { + Guild = guild, + Name = "general" + }; + db.Add(defaultChannel); + + await db.SaveChangesAsync(); + + return Ok(new Guilds.Guild( + guild.Id.ToString(), + guild.Name, + [user.Id.ToString()], + [new Channels.PartialChannel(defaultChannel.Id.ToString(), defaultChannel.Name)]) + ); + } +} \ No newline at end of file diff --git a/Foxchat.Chat/Controllers/HelloController.cs b/Foxchat.Chat/Controllers/HelloController.cs index f0e3ca4..0c0c639 100644 --- a/Foxchat.Chat/Controllers/HelloController.cs +++ b/Foxchat.Chat/Controllers/HelloController.cs @@ -10,7 +10,7 @@ using ApiError = Foxchat.Core.ApiError; namespace Foxchat.Chat.Controllers; [ApiController] -[Unauthenticated] +[ServerUnauthenticated] [Route("/_fox/chat/hello")] public class HelloController( ILogger logger, @@ -27,6 +27,8 @@ public class HelloController( if (!HttpContext.ExtractRequestData(out var signature, out var domain, out var signatureData)) throw new ApiError.IncomingFederationError("This endpoint requires signed requests."); + if (domain != req.Host) + throw new ApiError.IncomingFederationError("Host is invalid."); if (!requestSigningService.VerifySignature(node.PublicKey, signature, signatureData)) throw new ApiError.IncomingFederationError("Signature is not valid."); diff --git a/Foxchat.Chat/Database/Models/User.cs b/Foxchat.Chat/Database/Models/User.cs index 18a7054..26f2d5d 100644 --- a/Foxchat.Chat/Database/Models/User.cs +++ b/Foxchat.Chat/Database/Models/User.cs @@ -1,3 +1,6 @@ +using Foxchat.Core.Models; +using NodaTime; + namespace Foxchat.Chat.Database.Models; public class User : BaseModel @@ -8,6 +11,7 @@ public class User : BaseModel public string Username { get; init; } = null!; public string? Avatar { get; set; } + public Instant LastFetchedAt { get; set; } public List Guilds { get; } = []; public List OwnedGuilds { get; } = []; diff --git a/Foxchat.Chat/Extensions/WebApplicationExtensions.cs b/Foxchat.Chat/Extensions/WebApplicationExtensions.cs index 4900ba9..e48d404 100644 --- a/Foxchat.Chat/Extensions/WebApplicationExtensions.cs +++ b/Foxchat.Chat/Extensions/WebApplicationExtensions.cs @@ -1,4 +1,6 @@ using Foxchat.Chat.Middleware; +using Foxchat.Chat.Services; +using Foxchat.Core.Middleware; namespace Foxchat.Chat.Extensions; @@ -7,12 +9,20 @@ public static class WebApplicationExtensions public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) { return services - .AddScoped(); + .AddScoped() + .AddScoped(); } public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) { return app - .UseMiddleware(); + .UseMiddleware() + .UseMiddleware(); + } + + public static IServiceCollection AddChatServices(this IServiceCollection services) + { + return services + .AddScoped(); } } \ No newline at end of file diff --git a/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs b/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs similarity index 84% rename from Foxchat.Chat/Middleware/AuthenticationMiddleware.cs rename to Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs index cd522d8..daea8dc 100644 --- a/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs +++ b/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs @@ -7,14 +7,14 @@ using Microsoft.EntityFrameworkCore; namespace Foxchat.Chat.Middleware; -public class AuthenticationMiddleware(ILogger logger, ChatContext db, RequestSigningService requestSigningService) +public class ServerAuthenticationMiddleware(ILogger logger, ChatContext db, RequestSigningService requestSigningService) : IMiddleware { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { var endpoint = ctx.GetEndpoint(); // Endpoints require server authentication by default, unless they have the [Unauthenticated] attribute. - var metadata = endpoint?.Metadata.GetMetadata(); + var metadata = endpoint?.Metadata.GetMetadata(); if (metadata != null) { await next(ctx); @@ -41,8 +41,11 @@ public class AuthenticationMiddleware(ILogger logger, ChatContext db, RequestSig } } +/// +/// Attribute to be put on controllers or methods to indicate that it does not require a signed request. +/// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] -public class UnauthenticatedAttribute : Attribute; +public class ServerUnauthenticatedAttribute : Attribute; public static class HttpContextExtensions { diff --git a/Foxchat.Chat/Program.cs b/Foxchat.Chat/Program.cs index 6d94171..263167d 100644 --- a/Foxchat.Chat/Program.cs +++ b/Foxchat.Chat/Program.cs @@ -34,6 +34,7 @@ builder.Services builder.Services .AddCoreServices() + .AddChatServices() .AddCustomMiddleware() .AddEndpointsApiExplorer() .AddSwaggerGen(); diff --git a/Foxchat.Chat/Services/UserResolverService.cs b/Foxchat.Chat/Services/UserResolverService.cs new file mode 100644 index 0000000..87f56eb --- /dev/null +++ b/Foxchat.Chat/Services/UserResolverService.cs @@ -0,0 +1,35 @@ +using Foxchat.Chat.Database; +using Foxchat.Chat.Database.Models; +using Foxchat.Core.Federation; +using Foxchat.Core.Models; +using Microsoft.EntityFrameworkCore; + +namespace Foxchat.Chat.Services; + +public class UserResolverService(ILogger logger, ChatContext db, RequestSigningService requestSigningService) +{ + public async Task ResolveUserAsync(IdentityInstance instance, string userId) + { + var user = await db.Users.FirstOrDefaultAsync(u => u.InstanceId == instance.Id && u.RemoteUserId == userId); + if (user != null) + { + // TODO: update user if it's been long enough + return user; + } + + var userResponse = await requestSigningService.RequestAsync(HttpMethod.Get, instance.Domain, + $"/_fox/ident/users/{userId}"); + + user = new User + { + Instance = instance, + Username = userResponse.Username, + RemoteUserId = userResponse.Id, + Avatar = userResponse.AvatarUrl + }; + + db.Add(user); + await db.SaveChangesAsync(); + return user; + } +} \ No newline at end of file diff --git a/Foxchat.Core/Extensions/HttpContextExtensions.cs b/Foxchat.Core/Extensions/HttpContextExtensions.cs index 9b5db59..f00c8e4 100644 --- a/Foxchat.Core/Extensions/HttpContextExtensions.cs +++ b/Foxchat.Core/Extensions/HttpContextExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Foxchat.Core.Federation; using Microsoft.AspNetCore.Http; @@ -5,11 +6,12 @@ namespace Foxchat.Core.Extensions; public static class HttpContextExtensions { - public static bool ExtractRequestData(this HttpContext ctx, out string signature, out string domain, out SignatureData data) + public static bool ExtractRequestData(this HttpContext ctx, [NotNullWhen(true)] out string? signature, + [NotNullWhen(true)] out string? domain, [NotNullWhen(true)] out SignatureData? data) { - signature = string.Empty; - domain = string.Empty; - data = SignatureData.Empty; + signature = null; + domain = null; + data = null; if (!ctx.Request.Headers.TryGetValue(RequestSigningService.SIGNATURE_HEADER, out var encodedSignature)) return false; diff --git a/Foxchat.Core/Federation/RequestSigningService.cs b/Foxchat.Core/Federation/RequestSigningService.cs index 54ec6bf..2185277 100644 --- a/Foxchat.Core/Federation/RequestSigningService.cs +++ b/Foxchat.Core/Federation/RequestSigningService.cs @@ -33,18 +33,17 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba public bool VerifySignature( string publicKey, string encodedSignature, SignatureData data) { - var rsa = RSA.Create(); - rsa.ImportFromPem(publicKey); + if (data.Host != _config.Domain) + throw new ApiError.IncomingFederationError("Request is not for this instance"); var now = _clock.GetCurrentInstant(); - if ((now + Duration.FromMinutes(1)) < data.Time) - { + if (now + Duration.FromMinutes(1) < data.Time) throw new ApiError.IncomingFederationError("Request was made in the future"); - } - else if ((now - Duration.FromMinutes(1)) > data.Time) - { + if (now - Duration.FromMinutes(1) > data.Time) throw new ApiError.IncomingFederationError("Request was made too long ago"); - } + + var rsa = RSA.Create(); + rsa.ImportFromPem(publicKey); var plaintext = GeneratePlaintext(data); var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); @@ -70,7 +69,9 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba return $"{time}:{data.Host}:{data.RequestPath}:{contentLength}:{userId}"; } - private static readonly InstantPattern _pattern = InstantPattern.Create("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.GetCultureInfo("en-US")); + private static readonly InstantPattern _pattern = + InstantPattern.Create("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.GetCultureInfo("en-US")); + private static string FormatTime(Instant time) => _pattern.Format(time); public static Instant ParseTime(string header) => _pattern.Parse(header).GetValueOrThrow(); -} +} \ No newline at end of file diff --git a/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs b/Foxchat.Core/Middleware/ErrorHandlerMiddleware.cs similarity index 95% rename from Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs rename to Foxchat.Core/Middleware/ErrorHandlerMiddleware.cs index 6b8d7e5..0f34dd1 100644 --- a/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs +++ b/Foxchat.Core/Middleware/ErrorHandlerMiddleware.cs @@ -1,11 +1,10 @@ using System.Net; -using Foxchat.Core; using Foxchat.Core.Models.Http; +using Microsoft.AspNetCore.Http; using Newtonsoft.Json; -using ApiError = Foxchat.Core.ApiError; using HttpApiError = Foxchat.Core.Models.Http.ApiError; -namespace Foxchat.Identity.Middleware; +namespace Foxchat.Core.Middleware; public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware { @@ -23,7 +22,8 @@ public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware if (ctx.Response.HasStarted) { - logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName, ctx.Request.Path); + logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName, + ctx.Request.Path); } if (e is ApiError ae) diff --git a/Foxchat.Core/Models/Channels.cs b/Foxchat.Core/Models/Channels.cs new file mode 100644 index 0000000..b84c114 --- /dev/null +++ b/Foxchat.Core/Models/Channels.cs @@ -0,0 +1,8 @@ +namespace Foxchat.Core.Models; + +public static class Channels +{ + public record Channel(string Id, string GuildId, string Name, string? Topic); + + public record PartialChannel(string Id, string Name); +} \ No newline at end of file diff --git a/Foxchat.Core/Models/Guilds.cs b/Foxchat.Core/Models/Guilds.cs new file mode 100644 index 0000000..ac0a6a4 --- /dev/null +++ b/Foxchat.Core/Models/Guilds.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Foxchat.Core.Models; + +public static class Guilds +{ + public record Guild( + string Id, + string Name, + IEnumerable OwnerIds, + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + IEnumerable? Channels + ); +} \ No newline at end of file diff --git a/Foxchat.Core/Models/Http/Apps.cs b/Foxchat.Core/Models/Http/AppsApi.cs similarity index 93% rename from Foxchat.Core/Models/Http/Apps.cs rename to Foxchat.Core/Models/Http/AppsApi.cs index 8b670f4..909ce4d 100644 --- a/Foxchat.Core/Models/Http/Apps.cs +++ b/Foxchat.Core/Models/Http/AppsApi.cs @@ -1,6 +1,6 @@ namespace Foxchat.Core.Models.Http; -public static class Apps +public static class AppsApi { public record CreateRequest(string Name, string[] Scopes, string[] RedirectUris); public record CreateResponse(Ulid Id, string ClientId, string ClientSecret, string Name, string[] Scopes, string[] RedirectUris); diff --git a/Foxchat.Core/Models/Http/GuildsApi.cs b/Foxchat.Core/Models/Http/GuildsApi.cs new file mode 100644 index 0000000..e9f7eaf --- /dev/null +++ b/Foxchat.Core/Models/Http/GuildsApi.cs @@ -0,0 +1,6 @@ +namespace Foxchat.Core.Models.Http; + +public static class GuildsApi +{ + public record CreateGuildRequest(string Name); +} \ No newline at end of file diff --git a/Foxchat.Core/Models/Users.cs b/Foxchat.Core/Models/Users.cs new file mode 100644 index 0000000..6914a44 --- /dev/null +++ b/Foxchat.Core/Models/Users.cs @@ -0,0 +1,8 @@ +namespace Foxchat.Core.Models; + +public static class Users +{ + public record User(string Id, string Username, string Instance, string? AvatarUrl); + + public record PartialUser(string Id, string Username, string Instance); +} \ No newline at end of file diff --git a/Foxchat.Identity/Controllers/Oauth/AppsController.cs b/Foxchat.Identity/Controllers/Oauth/AppsController.cs index 402b138..1db1b74 100644 --- a/Foxchat.Identity/Controllers/Oauth/AppsController.cs +++ b/Foxchat.Identity/Controllers/Oauth/AppsController.cs @@ -9,12 +9,12 @@ using Microsoft.AspNetCore.Mvc; namespace Foxchat.Identity.Controllers.Oauth; [ApiController] -[Authenticate] +[ClientAuthenticate] [Route("/_fox/ident/oauth/apps")] public class AppsController(ILogger logger, IdentityContext db) : ControllerBase { [HttpPost] - public async Task CreateApplication([FromBody] Apps.CreateRequest req) + public async Task CreateApplication([FromBody] AppsApi.CreateRequest req) { var app = Application.Create(req.Name, req.Scopes, req.RedirectUris); db.Add(app); @@ -22,7 +22,7 @@ public class AppsController(ILogger logger, IdentityContext db) : ControllerBase logger.Information("Created new application {Name} with ID {Id} and client ID {ClientId}", app.Name, app.Id, app.ClientId); - return Ok(new Apps.CreateResponse( + return Ok(new AppsApi.CreateResponse( app.Id, app.ClientId, app.ClientSecret, app.Name, app.Scopes, app.RedirectUris )); } @@ -32,7 +32,7 @@ public class AppsController(ILogger logger, IdentityContext db) : ControllerBase { var app = HttpContext.GetApplicationOrThrow(); - return Ok(new Apps.GetSelfResponse( + return Ok(new AppsApi.GetSelfResponse( app.Id, app.ClientId, withSecret ? app.ClientSecret : null, diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs index 2d2c729..796edb9 100644 --- a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs +++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore; namespace Foxchat.Identity.Controllers.Oauth; [ApiController] -[Authenticate] +[ClientAuthenticate] [Route("/_fox/ident/oauth/password")] public class PasswordAuthController(ILogger logger, IdentityContext db, IClock clock) : ControllerBase { diff --git a/Foxchat.Identity/Controllers/UsersController.cs b/Foxchat.Identity/Controllers/UsersController.cs new file mode 100644 index 0000000..9e9c32d --- /dev/null +++ b/Foxchat.Identity/Controllers/UsersController.cs @@ -0,0 +1,21 @@ +using Foxchat.Core; +using Foxchat.Core.Models; +using Foxchat.Identity.Database; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Foxchat.Identity.Controllers; + +[ApiController] +[Route("/_fox/ident/users")] +public class UsersController(ILogger logger, InstanceConfig config, IdentityContext db) : ControllerBase +{ + [HttpGet("{id}")] + public async Task GetUser(Ulid id) + { + var user = await db.Accounts.FirstOrDefaultAsync(a => a.Id == id); + if (user == null) throw new ApiError.NotFound("User not found."); + + return Ok(new Users.User(user.Id.ToString(), user.Username, config.Domain, null)); + } +} \ No newline at end of file diff --git a/Foxchat.Identity/Extensions/WebApplicationExtensions.cs b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs index 7597b43..63628ce 100644 --- a/Foxchat.Identity/Extensions/WebApplicationExtensions.cs +++ b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs @@ -1,3 +1,4 @@ +using Foxchat.Core.Middleware; using Foxchat.Identity.Middleware; namespace Foxchat.Identity.Extensions; @@ -8,15 +9,15 @@ public static class WebApplicationExtensions { return services .AddScoped() - .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); } public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) { return app .UseMiddleware() - .UseMiddleware() - .UseMiddleware(); + .UseMiddleware() + .UseMiddleware(); } } diff --git a/Foxchat.Identity/Middleware/AuthenticationMiddleware.cs b/Foxchat.Identity/Middleware/ClientAuthenticationMiddleware.cs similarity index 92% rename from Foxchat.Identity/Middleware/AuthenticationMiddleware.cs rename to Foxchat.Identity/Middleware/ClientAuthenticationMiddleware.cs index 224e485..4ad5b2c 100644 --- a/Foxchat.Identity/Middleware/AuthenticationMiddleware.cs +++ b/Foxchat.Identity/Middleware/ClientAuthenticationMiddleware.cs @@ -8,7 +8,7 @@ using NodaTime; namespace Foxchat.Identity.Middleware; -public class AuthenticationMiddleware( +public class ClientAuthenticationMiddleware( IdentityContext db, IClock clock ) : IMiddleware @@ -16,7 +16,7 @@ public class AuthenticationMiddleware( public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { var endpoint = ctx.GetEndpoint(); - var metadata = endpoint?.Metadata.GetMetadata(); + var metadata = endpoint?.Metadata.GetMetadata(); if (metadata == null) { @@ -81,4 +81,4 @@ public static class HttpContextExtensions } [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] -public class AuthenticateAttribute : Attribute; +public class ClientAuthenticateAttribute : Attribute; diff --git a/Foxchat.Identity/Middleware/AuthorizationMiddleware.cs b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs similarity index 96% rename from Foxchat.Identity/Middleware/AuthorizationMiddleware.cs rename to Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs index 46a2fe6..92df517 100644 --- a/Foxchat.Identity/Middleware/AuthorizationMiddleware.cs +++ b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs @@ -4,7 +4,7 @@ using NodaTime; namespace Foxchat.Identity.Middleware; -public class AuthorizationMiddleware( +public class ClientAuthorizationMiddleware( IdentityContext db, IClock clock ) : IMiddleware From b95fb76cd424bb401553f1417576380d5aca9594 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 21 May 2024 21:21:34 +0200 Subject: [PATCH 07/11] identity: add proxy controller --- ...191115_AddLastFetchedAtToUsers.Designer.cs | 329 ++++++++++++++++++ .../20240521191115_AddLastFetchedAtToUsers.cs | 30 ++ .../Migrations/ChatContextModelSnapshot.cs | 4 + .../Oauth/PasswordAuthController.cs | 11 +- .../Controllers/Oauth/TokenController.cs | 6 +- .../Proxy/GuildsProxyController.cs | 21 ++ .../Controllers/Proxy/ProxyControllerBase.cs | 38 ++ .../ClientAuthorizationMiddleware.cs | 7 +- Foxchat.Identity/Utils/OauthUtils.cs | 10 +- 9 files changed, 446 insertions(+), 10 deletions(-) create mode 100644 Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs create mode 100644 Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs create mode 100644 Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs create mode 100644 Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs diff --git a/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs new file mode 100644 index 0000000..745ab75 --- /dev/null +++ b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs @@ -0,0 +1,329 @@ +// +using System; +using Foxchat.Chat.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxchat.Chat.Migrations +{ + [DbContext(typeof(ChatContext))] + [Migration("20240521191115_AddLastFetchedAtToUsers")] + partial class AddLastFetchedAtToUsers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GuildId") + .HasColumnType("uuid") + .HasColumnName("guild_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Topic") + .HasColumnType("text") + .HasColumnName("topic"); + + b.HasKey("Id") + .HasName("pk_channels"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_channels_guild_id"); + + b.ToTable("channels", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("pk_guilds"); + + b.HasIndex("OwnerId") + .HasDatabaseName("ix_guilds_owner_id"); + + b.ToTable("guilds", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.IdentityInstance", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("base_url"); + + b.Property("Domain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("public_key"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_identity_instances"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_identity_instances_domain"); + + b.ToTable("identity_instances", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ChannelId") + .HasColumnType("uuid") + .HasColumnName("channel_id"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_messages"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_messages_author_id"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_messages_channel_id"); + + b.ToTable("messages", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasColumnName("instance_id"); + + b.Property("LastFetchedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_fetched_at"); + + b.Property("RemoteUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("remote_user_id"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("InstanceId") + .HasDatabaseName("ix_users_instance_id"); + + b.HasIndex("RemoteUserId", "InstanceId") + .IsUnique() + .HasDatabaseName("ix_users_remote_user_id_instance_id"); + + b.HasIndex("Username", "InstanceId") + .IsUnique() + .HasDatabaseName("ix_users_username_instance_id"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Foxchat.Core.Database.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("private_key"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("public_key"); + + b.HasKey("Id") + .HasName("pk_instance"); + + b.ToTable("instance", (string)null); + }); + + modelBuilder.Entity("GuildUser", b => + { + b.Property("GuildsId") + .HasColumnType("uuid") + .HasColumnName("guilds_id"); + + b.Property("UsersId") + .HasColumnType("uuid") + .HasColumnName("users_id"); + + b.HasKey("GuildsId", "UsersId") + .HasName("pk_guild_user"); + + b.HasIndex("UsersId") + .HasDatabaseName("ix_guild_user_users_id"); + + b.ToTable("guild_user", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b => + { + b.HasOne("Foxchat.Chat.Database.Models.Guild", "Guild") + .WithMany("Channels") + .HasForeignKey("GuildId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_channels_guilds_guild_id"); + + b.Navigation("Guild"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.HasOne("Foxchat.Chat.Database.Models.User", "Owner") + .WithMany("OwnedGuilds") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guilds_users_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b => + { + b.HasOne("Foxchat.Chat.Database.Models.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_messages_users_author_id"); + + b.HasOne("Foxchat.Chat.Database.Models.Channel", "Channel") + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_messages_channels_channel_id"); + + b.Navigation("Author"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.HasOne("Foxchat.Chat.Database.Models.IdentityInstance", "Instance") + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_users_identity_instances_instance_id"); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("GuildUser", b => + { + b.HasOne("Foxchat.Chat.Database.Models.Guild", null) + .WithMany() + .HasForeignKey("GuildsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_user_guilds_guilds_id"); + + b.HasOne("Foxchat.Chat.Database.Models.User", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_user_users_users_id"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.Navigation("OwnedGuilds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs new file mode 100644 index 0000000..f4a99ba --- /dev/null +++ b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace Foxchat.Chat.Migrations +{ + /// + public partial class AddLastFetchedAtToUsers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "last_fetched_at", + table: "users", + type: "timestamp with time zone", + nullable: false, + defaultValue: NodaTime.Instant.FromUnixTimeTicks(0L)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "last_fetched_at", + table: "users"); + } + } +} diff --git a/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs index f560113..184e756 100644 --- a/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs +++ b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs @@ -162,6 +162,10 @@ namespace Foxchat.Chat.Migrations .HasColumnType("uuid") .HasColumnName("instance_id"); + b.Property("LastFetchedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_fetched_at"); + b.Property("RemoteUserId") .IsRequired() .HasColumnType("text") diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs index 796edb9..a4f5080 100644 --- a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs +++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Identity; using Foxchat.Identity.Database.Models; using Foxchat.Core; using System.Diagnostics; +using Foxchat.Identity.Utils; using NodaTime; using Microsoft.EntityFrameworkCore; @@ -24,10 +25,11 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c var appToken = HttpContext.GetToken() ?? throw new UnreachableException(); // GetApplicationOrThrow already gets the token and throws if it's null + var appScopes = appToken.ExpandScopes(); - if (req.Scopes.Except(appToken.Scopes).Any()) + if (req.Scopes.Except(appScopes).Any()) throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", - req.Scopes.Except(appToken.Scopes)); + req.Scopes.Except(appScopes)); var acct = new Account { @@ -52,10 +54,11 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c { var app = HttpContext.GetApplicationOrThrow(); var appToken = HttpContext.GetToken() ?? throw new UnreachableException(); + var appScopes = appToken.ExpandScopes(); - if (req.Scopes.Except(appToken.Scopes).Any()) + if (req.Scopes.Except(appScopes).Any()) throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", - req.Scopes.Except(appToken.Scopes)); + req.Scopes.Except(appScopes)); 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"); diff --git a/Foxchat.Identity/Controllers/Oauth/TokenController.cs b/Foxchat.Identity/Controllers/Oauth/TokenController.cs index b01ee77..ed7dfc8 100644 --- a/Foxchat.Identity/Controllers/Oauth/TokenController.cs +++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs @@ -1,6 +1,7 @@ using Foxchat.Core; using Foxchat.Identity.Database; using Foxchat.Identity.Database.Models; +using Foxchat.Identity.Utils; using Microsoft.AspNetCore.Mvc; using NodaTime; @@ -14,11 +15,12 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) : public async Task PostToken([FromBody] PostTokenRequest req) { var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret); + var appScopes = app.ExpandScopes(); var scopes = req.Scope.Split(' '); - if (scopes.Except(app.Scopes).Any()) + if (scopes.Except(appScopes).Any()) { - throw new ApiError.BadRequest("Invalid or unauthorized scopes"); + throw new ApiError.Forbidden("Invalid or unauthorized scopes", scopes.Except(appScopes)); } switch (req.GrantType) diff --git a/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs b/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs new file mode 100644 index 0000000..9b5a8d4 --- /dev/null +++ b/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs @@ -0,0 +1,21 @@ +using Foxchat.Core.Federation; +using Foxchat.Core.Models; +using Foxchat.Core.Models.Http; +using Foxchat.Identity.Middleware; +using Foxchat.Identity.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Foxchat.Identity.Controllers.Proxy; + +[Route("/_fox/proxy/guilds")] +public class GuildsProxyController( + ILogger logger, + ChatInstanceResolverService chatInstanceResolverService, + RequestSigningService requestSigningService) + : ProxyControllerBase(logger, chatInstanceResolverService, requestSigningService) +{ + [Authorize("chat_client")] + [HttpPost] + public Task CreateGuild([FromBody] GuildsApi.CreateGuildRequest req) => + Proxy(HttpMethod.Post, req); +} \ No newline at end of file diff --git a/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs b/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs new file mode 100644 index 0000000..4728ca4 --- /dev/null +++ b/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs @@ -0,0 +1,38 @@ +using Foxchat.Core; +using Foxchat.Core.Federation; +using Foxchat.Identity.Middleware; +using Foxchat.Identity.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Foxchat.Identity.Controllers.Proxy; + +[ApiController] +[ClientAuthenticate] +public class ProxyControllerBase( + ILogger logger, + ChatInstanceResolverService chatInstanceResolverService, + RequestSigningService requestSigningService) : ControllerBase +{ + internal async Task Proxy(HttpMethod method, object? body = null) where TResponse : class + { + var acct = HttpContext.GetAccountOrThrow(); + + var path = HttpContext.Request.Path.ToString(); + if (!path.StartsWith("/_fox/proxy")) + throw new FoxchatError("Proxy used for endpoint that does not start with /_fox/proxy"); + path = $"/_fox/chat/{path[12..]}"; + + if (!HttpContext.Request.Headers.TryGetValue(RequestSigningService.SERVER_HEADER, out var serverHeader)) + throw new ApiError.BadRequest($"Invalid or missing {RequestSigningService.SERVER_HEADER} header."); + var server = serverHeader.ToString(); + + logger.Debug("Proxying {Method} request to {Domain}{Path}", method, server, path); + + // Identity instances always initiate federation, so we have to make sure the instance knows about us. + // This also serves as a way to make sure the instance being requested actually exists. + await chatInstanceResolverService.ResolveChatInstanceAsync(serverHeader.ToString()); + + var resp = await requestSigningService.RequestAsync(method, server, path, acct.Id.ToString(), body); + return Ok(resp); + } +} \ No newline at end of file diff --git a/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs index 92df517..701fb05 100644 --- a/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs +++ b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs @@ -1,5 +1,6 @@ using Foxchat.Core; using Foxchat.Identity.Database; +using Foxchat.Identity.Utils; using NodaTime; namespace Foxchat.Identity.Middleware; @@ -21,10 +22,10 @@ public class ClientAuthorizationMiddleware( } var token = ctx.GetToken(); - if (token == null || token.Expires > clock.GetCurrentInstant()) + if (token == null || token.Expires < clock.GetCurrentInstant()) throw new ApiError.Unauthorized("This endpoint requires an authenticated user."); - if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes).Any()) - throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.Scopes)); + if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.ExpandScopes()).Any()) + throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.ExpandScopes())); await next(ctx); } diff --git a/Foxchat.Identity/Utils/OauthUtils.cs b/Foxchat.Identity/Utils/OauthUtils.cs index 26188bb..bf698a2 100644 --- a/Foxchat.Identity/Utils/OauthUtils.cs +++ b/Foxchat.Identity/Utils/OauthUtils.cs @@ -24,4 +24,12 @@ public static class OauthUtils return false; } } -} + + public static string[] ExpandScopes(this Token token) => token.Scopes.Contains("chat_client") + ? Scopes + : token.Scopes; + + public static string[] ExpandScopes(this Application app) => app.Scopes.Contains("chat_client") + ? Scopes + : app.Scopes; +} \ No newline at end of file From 6aed05af06f2feac552ae8292f5a5ed474b10d67 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 22 May 2024 02:31:05 +0200 Subject: [PATCH 08/11] feat(core): add optional SQL query logging --- Foxchat.Chat/Database/ChatContext.cs | 10 +++++++--- Foxchat.Chat/Program.cs | 3 ++- Foxchat.Chat/chat.ini | 11 +++++++--- Foxchat.Core/CoreConfig.cs | 13 ++++++++---- .../Extensions/ServiceCollectionExtensions.cs | 20 ++++++++++--------- Foxchat.Identity/Database/IdentityContext.cs | 14 ++++++++----- Foxchat.Identity/Program.cs | 3 ++- Foxchat.Identity/identity.ini | 13 +++++++----- 8 files changed, 56 insertions(+), 31 deletions(-) diff --git a/Foxchat.Chat/Database/ChatContext.cs b/Foxchat.Chat/Database/ChatContext.cs index efbe8a1..f8ac2a5 100644 --- a/Foxchat.Chat/Database/ChatContext.cs +++ b/Foxchat.Chat/Database/ChatContext.cs @@ -1,6 +1,7 @@ using Foxchat.Chat.Database.Models; using Foxchat.Core; using Foxchat.Core.Database; +using Foxchat.Core.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Npgsql; @@ -10,6 +11,7 @@ namespace Foxchat.Chat.Database; public class ChatContext : IDatabaseContext { private readonly NpgsqlDataSource _dataSource; + private readonly ILoggerFactory? _loggerFactory; public override DbSet Instance { get; set; } public DbSet IdentityInstances { get; set; } @@ -18,7 +20,7 @@ public class ChatContext : IDatabaseContext public DbSet Channels { get; set; } public DbSet Messages { get; set; } - public ChatContext(InstanceConfig config) + public ChatContext(InstanceConfig config, ILoggerFactory? loggerFactory) { var connString = new NpgsqlConnectionStringBuilder(config.Database.Url) { @@ -29,12 +31,14 @@ public class ChatContext : IDatabaseContext var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString); dataSourceBuilder.UseNodaTime(); _dataSource = dataSourceBuilder.Build(); + _loggerFactory = loggerFactory; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder .UseNpgsql(_dataSource, o => o.UseNodaTime()) - .UseSnakeCaseNamingConvention(); + .UseSnakeCaseNamingConvention() + .UseLoggerFactory(_loggerFactory); protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { @@ -73,6 +77,6 @@ public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory() ?? new(); - return new ChatContext(config); + return new ChatContext(config, null); } } \ No newline at end of file diff --git a/Foxchat.Chat/Program.cs b/Foxchat.Chat/Program.cs index 263167d..fa2bbb6 100644 --- a/Foxchat.Chat/Program.cs +++ b/Foxchat.Chat/Program.cs @@ -4,13 +4,14 @@ using Foxchat.Core; using Foxchat.Chat; using Foxchat.Chat.Database; using Foxchat.Chat.Extensions; +using Foxchat.Core.Extensions; using Newtonsoft.Json; var builder = WebApplication.CreateBuilder(args); var config = builder.AddConfiguration("chat.ini"); -builder.AddSerilog(config.LogEventLevel); +builder.AddSerilog(); await BuildInfo.ReadBuildInfo(); Log.Information("Starting Foxchat.Chat {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash); diff --git a/Foxchat.Chat/chat.ini b/Foxchat.Chat/chat.ini index 21a32a0..906acdb 100644 --- a/Foxchat.Chat/chat.ini +++ b/Foxchat.Chat/chat.ini @@ -2,9 +2,6 @@ Host = localhost Port = 7610 Domain = chat.fox.localhost -; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal -LogEventLevel = Debug - [Database] ; The database URL in ADO.NET format. Url = "Host=localhost;Database=foxchat_cs_chat;Username=foxchat;Password=password" @@ -13,3 +10,11 @@ Url = "Host=localhost;Database=foxchat_cs_chat;Username=foxchat;Password=passwor Timeout = 5 ; The maximum number of open connections. Defaults to 50. MaxPoolSize = 500 + +[Logging] +; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal +LogEventLevel = Debug +; Whether to log SQL queries. +LogQueries = true +; Optional logging to Seq +SeqLogUrl = http://localhost:5341 \ No newline at end of file diff --git a/Foxchat.Core/CoreConfig.cs b/Foxchat.Core/CoreConfig.cs index 40a97b4..236af8a 100644 --- a/Foxchat.Core/CoreConfig.cs +++ b/Foxchat.Core/CoreConfig.cs @@ -11,9 +11,7 @@ public class CoreConfig public string Address => $"{(Secure ? "https" : "http")}://{Host}:{Port}"; - public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug; - public string? SeqLogUrl { get; set; } - + public LoggingConfig Logging { get; set; } = new(); public DatabaseConfig Database { get; set; } = new(); public class DatabaseConfig @@ -22,4 +20,11 @@ public class CoreConfig public int? Timeout { get; set; } public int? MaxPoolSize { get; set; } } -} + + public class LoggingConfig + { + public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug; + public string? SeqLogUrl { get; set; } + public bool LogQueries { get; set; } = false; + } +} \ No newline at end of file diff --git a/Foxchat.Core/Extensions/ServiceCollectionExtensions.cs b/Foxchat.Core/Extensions/ServiceCollectionExtensions.cs index f5d4893..a41dd0b 100644 --- a/Foxchat.Core/Extensions/ServiceCollectionExtensions.cs +++ b/Foxchat.Core/Extensions/ServiceCollectionExtensions.cs @@ -7,30 +7,32 @@ using NodaTime; using Serilog; using Serilog.Events; -namespace Foxchat.Core; +namespace Foxchat.Core.Extensions; public static class ServiceCollectionExtensions { /// /// Adds Serilog to this service collection. This method also initializes Serilog so it should be called as early as possible, before any log calls. /// - public static void AddSerilog(this WebApplicationBuilder builder, LogEventLevel level) + public static void AddSerilog(this WebApplicationBuilder builder) { var config = builder.Configuration.Get() ?? new(); var logCfg = new LoggerConfiguration() .Enrich.FromLogContext() - .MinimumLevel.Is(level) + .MinimumLevel.Is(config.Logging.LogEventLevel) // ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead. - // Serilog doesn't disable the built in logs so we do it here. + // Serilog doesn't disable the built-in logs, so we do it here. .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", + config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) .WriteTo.Console(); - if (config.SeqLogUrl != null) - logCfg.WriteTo.Seq(config.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose); + if (config.Logging.SeqLogUrl != null) + logCfg.WriteTo.Seq(config.Logging.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose); Log.Logger = logCfg.CreateLogger(); @@ -54,9 +56,9 @@ public static class ServiceCollectionExtensions return services; } - public static T AddConfiguration(this WebApplicationBuilder builder, string? configFile = null) where T : class, new() + public static T AddConfiguration(this WebApplicationBuilder builder, string? configFile = null) + where T : class, new() { - builder.Configuration.Sources.Clear(); builder.Configuration.AddConfiguration(configFile); @@ -76,4 +78,4 @@ public static class ServiceCollectionExtensions .AddIniFile(file, optional: false, reloadOnChange: true) .AddEnvironmentVariables(); } -} +} \ No newline at end of file diff --git a/Foxchat.Identity/Database/IdentityContext.cs b/Foxchat.Identity/Database/IdentityContext.cs index e983fac..efc4850 100644 --- a/Foxchat.Identity/Database/IdentityContext.cs +++ b/Foxchat.Identity/Database/IdentityContext.cs @@ -1,5 +1,6 @@ using Foxchat.Core; using Foxchat.Core.Database; +using Foxchat.Core.Extensions; using Foxchat.Identity.Database.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; @@ -10,6 +11,7 @@ namespace Foxchat.Identity.Database; public class IdentityContext : IDatabaseContext { private readonly NpgsqlDataSource _dataSource; + private readonly ILoggerFactory? _loggerFactory; public override DbSet Instance { get; set; } public DbSet Accounts { get; set; } @@ -18,7 +20,7 @@ public class IdentityContext : IDatabaseContext public DbSet Tokens { get; set; } public DbSet GuildAccounts { get; set; } - public IdentityContext(InstanceConfig config) + public IdentityContext(InstanceConfig config, ILoggerFactory? loggerFactory) { var connString = new NpgsqlConnectionStringBuilder(config.Database.Url) { @@ -29,12 +31,14 @@ public class IdentityContext : IDatabaseContext var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString); dataSourceBuilder.UseNodaTime(); _dataSource = dataSourceBuilder.Build(); + _loggerFactory = loggerFactory; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder - .UseNpgsql(_dataSource, o => o.UseNodaTime()) - .UseSnakeCaseNamingConvention(); + => optionsBuilder + .UseNpgsql(_dataSource, o => o.UseNodaTime()) + .UseSnakeCaseNamingConvention() + .UseLoggerFactory(_loggerFactory); protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { @@ -67,6 +71,6 @@ public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory() ?? new(); - return new IdentityContext(config); + return new IdentityContext(config, null); } } diff --git a/Foxchat.Identity/Program.cs b/Foxchat.Identity/Program.cs index 8e0c47e..035feb1 100644 --- a/Foxchat.Identity/Program.cs +++ b/Foxchat.Identity/Program.cs @@ -1,6 +1,7 @@ using Newtonsoft.Json.Serialization; using Serilog; using Foxchat.Core; +using Foxchat.Core.Extensions; using Foxchat.Identity; using Foxchat.Identity.Database; using Foxchat.Identity.Services; @@ -11,7 +12,7 @@ var builder = WebApplication.CreateBuilder(args); var config = builder.AddConfiguration("identity.ini"); -builder.AddSerilog(config.LogEventLevel); +builder.AddSerilog(); await BuildInfo.ReadBuildInfo(); Log.Information("Starting Foxchat.Identity {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash); diff --git a/Foxchat.Identity/identity.ini b/Foxchat.Identity/identity.ini index d4b3c40..7f4172c 100644 --- a/Foxchat.Identity/identity.ini +++ b/Foxchat.Identity/identity.ini @@ -2,11 +2,6 @@ Host = localhost Port = 7611 Domain = id.fox.localhost -; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal -LogEventLevel = Debug -; Optional logging to Seq -SeqLogUrl = http://localhost:5341 - [Database] ; The database URL in ADO.NET format. Url = "Host=localhost;Database=foxchat_cs_ident;Username=foxchat;Password=password" @@ -15,3 +10,11 @@ Url = "Host=localhost;Database=foxchat_cs_ident;Username=foxchat;Password=passwo Timeout = 5 ; The maximum number of open connections. Defaults to 50. MaxPoolSize = 500 + +[Logging] +; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal +LogEventLevel = Debug +; Whether to log SQL queries. +LogQueries = true +; Optional logging to Seq +SeqLogUrl = http://localhost:5341 From 00a54f4f8bcc80d0ec52308091bea69d531e19dc Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 22 May 2024 17:17:36 +0200 Subject: [PATCH 09/11] feat(chat): add /guilds/{id} and /guilds/@me endpoints --- .../Controllers/Api/GuildsController.cs | 47 +++++++++++++++++-- .../ServerAuthenticationMiddleware.cs | 7 +++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/Foxchat.Chat/Controllers/Api/GuildsController.cs b/Foxchat.Chat/Controllers/Api/GuildsController.cs index ab99a6d..bd8d0bb 100644 --- a/Foxchat.Chat/Controllers/Api/GuildsController.cs +++ b/Foxchat.Chat/Controllers/Api/GuildsController.cs @@ -5,6 +5,7 @@ using Foxchat.Chat.Services; using Foxchat.Core.Models; using Foxchat.Core.Models.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using ApiError = Foxchat.Core.ApiError; namespace Foxchat.Chat.Controllers.Api; @@ -16,10 +17,9 @@ public class GuildsController(ILogger logger, ChatContext db, UserResolverServic [HttpPost] public async Task CreateGuild([FromBody] GuildsApi.CreateGuildRequest req) { - var (instance, sig) = HttpContext.GetSignatureOrThrow(); - if (sig.UserId == null) throw new ApiError.IncomingFederationError("This endpoint requires a user ID."); + var (instance, _, userId) = HttpContext.GetSignatureWithUser(); - var user = await userResolverService.ResolveUserAsync(instance, sig.UserId); + var user = await userResolverService.ResolveUserAsync(instance, userId); var guild = new Guild { @@ -38,10 +38,47 @@ public class GuildsController(ILogger logger, ChatContext db, UserResolverServic await db.SaveChangesAsync(); return Ok(new Guilds.Guild( - guild.Id.ToString(), - guild.Name, + guild.Id.ToString(), + guild.Name, [user.Id.ToString()], [new Channels.PartialChannel(defaultChannel.Id.ToString(), defaultChannel.Name)]) ); } + + [HttpGet("{id}")] + public async Task GetGuild(Ulid id) + { + var (instance, _, userId) = HttpContext.GetSignatureWithUser(); + var guild = await db.Guilds + .Include(g => g.Channels) + .FirstOrDefaultAsync(g => + g.Id == id && g.Users.Any(u => u.RemoteUserId == userId && u.InstanceId == instance.Id)); + if (guild == null) throw new ApiError.NotFound("Guild not found"); + + return Ok(new Guilds.Guild( + guild.Id.ToString(), + guild.Name, + [guild.OwnerId.ToString()], + guild.Channels.Select(c => new Channels.PartialChannel(c.Id.ToString(), c.Name)) + )); + } + + [HttpGet("@me")] + public async Task GetUserGuilds() + { + var (instance, _, userId) = HttpContext.GetSignatureWithUser(); + var guilds = await db.Guilds + .Include(g => g.Channels) + .Where(g => g.Users.Any(u => u.RemoteUserId == userId && u.InstanceId == instance.Id)) + .ToListAsync(); + + var guildResponses = guilds.Select(g => new Guilds.Guild( + g.Id.ToString(), + g.Name, + [g.OwnerId.ToString()], + g.Channels.Select(c => new Channels.PartialChannel(c.Id.ToString(), c.Name)) + )); + + return Ok(guildResponses); + } } \ No newline at end of file diff --git a/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs b/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs index daea8dc..4db585a 100644 --- a/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs +++ b/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs @@ -76,4 +76,11 @@ public static class HttpContextExtensions return ((IdentityInstance, SignatureData))obj!; } + + public static (IdentityInstance, SignatureData, string) GetSignatureWithUser(this HttpContext ctx) + { + var (instance, sig) = ctx.GetSignatureOrThrow(); + if (sig.UserId == null) throw new ApiError.IncomingFederationError("This endpoint requires a user ID."); + return (instance, sig, sig.UserId); + } } \ No newline at end of file From 8bd118ea670e4f331cd3223b4ef08a6b6c820509 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 22 May 2024 17:19:45 +0200 Subject: [PATCH 10/11] refactor(identity): change receiver of OauthUtils.ExpandScopes() --- .../Controllers/Oauth/PasswordAuthController.cs | 4 ++-- .../Controllers/Oauth/TokenController.cs | 6 +++--- .../Middleware/ClientAuthorizationMiddleware.cs | 4 ++-- Foxchat.Identity/Utils/OauthUtils.cs | 13 ++++++------- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs index a4f5080..06a6a6e 100644 --- a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs +++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs @@ -25,7 +25,7 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c var appToken = HttpContext.GetToken() ?? throw new UnreachableException(); // GetApplicationOrThrow already gets the token and throws if it's null - var appScopes = appToken.ExpandScopes(); + var appScopes = appToken.Scopes.ExpandScopes(); if (req.Scopes.Except(appScopes).Any()) throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", @@ -54,7 +54,7 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c { var app = HttpContext.GetApplicationOrThrow(); var appToken = HttpContext.GetToken() ?? throw new UnreachableException(); - var appScopes = appToken.ExpandScopes(); + var appScopes = appToken.Scopes.ExpandScopes(); if (req.Scopes.Except(appScopes).Any()) throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", diff --git a/Foxchat.Identity/Controllers/Oauth/TokenController.cs b/Foxchat.Identity/Controllers/Oauth/TokenController.cs index ed7dfc8..20c5924 100644 --- a/Foxchat.Identity/Controllers/Oauth/TokenController.cs +++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs @@ -15,7 +15,7 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) : public async Task PostToken([FromBody] PostTokenRequest req) { var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret); - var appScopes = app.ExpandScopes(); + var appScopes = app.Scopes.ExpandScopes(); var scopes = req.Scope.Split(' '); if (scopes.Except(appScopes).Any()) @@ -25,9 +25,9 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) : switch (req.GrantType) { - case "client_credentials": + case OauthUtils.ClientCredentials: return await HandleClientCredentialsAsync(app, scopes); - case "authorization_code": + case OauthUtils.AuthorizationCode: // TODO break; default: diff --git a/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs index 701fb05..2e6499d 100644 --- a/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs +++ b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs @@ -24,8 +24,8 @@ public class ClientAuthorizationMiddleware( var token = ctx.GetToken(); if (token == null || token.Expires < clock.GetCurrentInstant()) throw new ApiError.Unauthorized("This endpoint requires an authenticated user."); - if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.ExpandScopes()).Any()) - throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.ExpandScopes())); + if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()) + throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.Scopes.ExpandScopes())); await next(ctx); } diff --git a/Foxchat.Identity/Utils/OauthUtils.cs b/Foxchat.Identity/Utils/OauthUtils.cs index bf698a2..d6d5b2c 100644 --- a/Foxchat.Identity/Utils/OauthUtils.cs +++ b/Foxchat.Identity/Utils/OauthUtils.cs @@ -6,7 +6,10 @@ namespace Foxchat.Identity.Utils; public static class OauthUtils { - public static readonly string[] Scopes = ["identify", "chat_client"]; + public const string ClientCredentials = "client_credentials"; + public const string AuthorizationCode = "authorization_code"; + + public static readonly string[] Scopes = ["identify", "email", "guilds", "chat_client"]; private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"]; private const string OobUri = "urn:ietf:wg:oauth:2.0:oob"; @@ -25,11 +28,7 @@ public static class OauthUtils } } - public static string[] ExpandScopes(this Token token) => token.Scopes.Contains("chat_client") + public static string[] ExpandScopes(this string[] scopes) => scopes.Contains("chat_client") ? Scopes - : token.Scopes; - - public static string[] ExpandScopes(this Application app) => app.Scopes.Contains("chat_client") - ? Scopes - : app.Scopes; + : scopes; } \ No newline at end of file From 291941311808cbde7c937cc4e48dc4e20bdae413 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 22 May 2024 17:20:00 +0200 Subject: [PATCH 11/11] feat(identity): add /users/@me endpoint --- .../Controllers/UsersController.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Foxchat.Identity/Controllers/UsersController.cs b/Foxchat.Identity/Controllers/UsersController.cs index 9e9c32d..6302183 100644 --- a/Foxchat.Identity/Controllers/UsersController.cs +++ b/Foxchat.Identity/Controllers/UsersController.cs @@ -1,12 +1,17 @@ using Foxchat.Core; using Foxchat.Core.Models; using Foxchat.Identity.Database; +using Foxchat.Identity.Database.Models; +using Foxchat.Identity.Middleware; +using Foxchat.Identity.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; namespace Foxchat.Identity.Controllers; [ApiController] +[ClientAuthenticate] [Route("/_fox/ident/users")] public class UsersController(ILogger logger, InstanceConfig config, IdentityContext db) : ControllerBase { @@ -18,4 +23,30 @@ public class UsersController(ILogger logger, InstanceConfig config, IdentityCont return Ok(new Users.User(user.Id.ToString(), user.Username, config.Domain, null)); } + + [HttpGet("@me")] + [Authorize("identify")] + public IActionResult GetMe() + { + var acct = HttpContext.GetAccountOrThrow(); + var token = HttpContext.GetToken()!; + var showEmail = token.Scopes.ExpandScopes().Contains("email"); + + return Ok(new MeUser( + acct.Id, + acct.Username, + acct.Role, + null, + showEmail ? acct.Email : null + )); + } + + public record MeUser( + Ulid Id, + string Username, + Account.AccountRole Role, + string? AvatarUrl, + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + string? Email + ); } \ No newline at end of file