add error handler middleware

This commit is contained in:
sam 2024-05-20 19:42:04 +02:00
parent 41e4dda7b4
commit 7a0247b551
13 changed files with 177 additions and 46 deletions

View file

@ -1,5 +1,4 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using Foxchat.Core.Models;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Serialization; using Newtonsoft.Json.Serialization;
@ -15,14 +14,6 @@ public partial class RequestSigningService
public const string SIGNATURE_HEADER = "X-Foxchat-Signature"; public const string SIGNATURE_HEADER = "X-Foxchat-Signature";
public const string USER_HEADER = "X-Foxchat-User"; public const string USER_HEADER = "X-Foxchat-User";
private static readonly JsonSerializerSettings _jsonSerializerSettings = new()
{
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy()
}
};
public async Task<T> RequestAsync<T>(HttpMethod method, string domain, string requestPath, string? userId = null, object? body = null) public async Task<T> RequestAsync<T>(HttpMethod method, string domain, string requestPath, string? userId = null, object? body = null)
{ {
var request = BuildHttpRequest(method, domain, requestPath, userId, body); var request = BuildHttpRequest(method, domain, requestPath, userId, body);
@ -30,17 +21,17 @@ public partial class RequestSigningService
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
var error = await resp.Content.ReadAsStringAsync(); var error = await resp.Content.ReadAsStringAsync();
throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", DeserializeObject<Models.ApiError>(error)); throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", JsonConvert.DeserializeObject<Models.Http.ApiError>(error));
} }
var bodyString = await resp.Content.ReadAsStringAsync(); var bodyString = await resp.Content.ReadAsStringAsync();
return DeserializeObject<T>(bodyString) return JsonConvert.DeserializeObject<T>(bodyString)
?? throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned invalid response body"); ?? 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) 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 now = _clock.GetCurrentInstant();
var url = $"https://{domain}{requestPath}"; var url = $"https://{domain}{requestPath}";
@ -59,7 +50,4 @@ public partial class RequestSigningService
return request; return request;
} }
public static string SerializeObject(object data) => JsonConvert.SerializeObject(data, _jsonSerializerSettings);
public static T? DeserializeObject<T>(string data) => JsonConvert.DeserializeObject<T>(data, _jsonSerializerSettings);
} }

View file

@ -2,10 +2,12 @@ using System.Net;
namespace Foxchat.Core; 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 Exception? Inner => inner;
public class UnknownEntityError(Type entityType) : FoxchatError($"Entity of type {entityType.Name} not found");
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) 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 IncomingFederationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest);
public class OutgoingFederationError( public class OutgoingFederationError(
string message, Models.ApiError? innerError = null string message, Models.Http.ApiError? innerError = null
) : ApiError(message, statusCode: HttpStatusCode.InternalServerError) ) : 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); public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest);

View file

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

View file

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

View file

@ -1,8 +1,9 @@
using Foxchat.Core; using Foxchat.Core;
using Foxchat.Core.Models.Http; using Foxchat.Core.Models.Http;
using Foxchat.Identity.Authorization; using Foxchat.Identity.Middleware;
using Foxchat.Identity.Database; using Foxchat.Identity.Database;
using Foxchat.Identity.Database.Models; using Foxchat.Identity.Database.Models;
using Foxchat.Identity.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Foxchat.Identity.Controllers.Oauth; namespace Foxchat.Identity.Controllers.Oauth;
@ -29,9 +30,7 @@ public class AppsController(ILogger logger, IdentityContext db) : ControllerBase
[HttpGet] [HttpGet]
public IActionResult GetSelfApp([FromQuery(Name = "with_secret")] bool withSecret) public IActionResult GetSelfApp([FromQuery(Name = "with_secret")] bool withSecret)
{ {
var token = HttpContext.GetToken(); var app = HttpContext.GetApplicationOrThrow();
if (token is not { Account: null }) throw new ApiError.Forbidden("This endpoint requires a client token.");
var app = token.Application;
return Ok(new Apps.GetSelfResponse( return Ok(new Apps.GetSelfResponse(
app.Id, app.Id,

View file

@ -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<IActionResult> Register()
{
var app = HttpContext.GetApplicationOrThrow();
throw new NotImplementedException();
}
public record RegisterRequest();
}

View file

@ -16,7 +16,7 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) :
var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret); var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret);
var scopes = req.Scope.Split(' '); 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"); throw new ApiError.BadRequest("Invalid or unauthorized scopes");
} }

View file

@ -1,4 +1,4 @@
using Foxchat.Identity.Authorization; using Foxchat.Identity.Middleware;
namespace Foxchat.Identity.Extensions; namespace Foxchat.Identity.Extensions;
@ -7,6 +7,7 @@ public static class WebApplicationExtensions
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) public static IServiceCollection AddCustomMiddleware(this IServiceCollection services)
{ {
return services return services
.AddScoped<ErrorHandlerMiddleware>()
.AddScoped<AuthenticationMiddleware>() .AddScoped<AuthenticationMiddleware>()
.AddScoped<AuthorizationMiddleware>(); .AddScoped<AuthorizationMiddleware>();
} }
@ -14,6 +15,7 @@ public static class WebApplicationExtensions
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app)
{ {
return app return app
.UseMiddleware<ErrorHandlerMiddleware>()
.UseMiddleware<AuthenticationMiddleware>() .UseMiddleware<AuthenticationMiddleware>()
.UseMiddleware<AuthorizationMiddleware>(); .UseMiddleware<AuthorizationMiddleware>();
} }

View file

@ -6,7 +6,7 @@ using Foxchat.Identity.Database.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace Foxchat.Identity.Authorization; namespace Foxchat.Identity.Middleware;
public class AuthenticationMiddleware( public class AuthenticationMiddleware(
IdentityContext db, IdentityContext db,

View file

@ -1,9 +1,8 @@
using System.Net;
using Foxchat.Core; using Foxchat.Core;
using Foxchat.Identity.Database; using Foxchat.Identity.Database;
using NodaTime; using NodaTime;
namespace Foxchat.Identity.Authorization; namespace Foxchat.Identity.Middleware;
public class AuthorizationMiddleware( public class AuthorizationMiddleware(
IdentityContext db, IdentityContext db,

View file

@ -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 ?? "<unknown>";
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",
}));
}
}
}

View file

@ -5,6 +5,7 @@ using Foxchat.Identity;
using Foxchat.Identity.Database; using Foxchat.Identity.Database;
using Foxchat.Identity.Services; using Foxchat.Identity.Services;
using Foxchat.Identity.Extensions; using Foxchat.Identity.Extensions;
using Newtonsoft.Json;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -15,6 +16,15 @@ builder.AddSerilog(config.LogEventLevel);
await BuildInfo.ReadBuildInfo(); await BuildInfo.ReadBuildInfo();
Log.Information("Starting Foxchat.Identity {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash); 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 builder.Services
.AddControllers() .AddControllers()
.AddNewtonsoftJson(options => .AddNewtonsoftJson(options =>

View file

@ -1,3 +1,7 @@
using Foxchat.Core;
using Foxchat.Identity.Middleware;
using Foxchat.Identity.Database.Models;
namespace Foxchat.Identity.Utils; namespace Foxchat.Identity.Utils;
public static class OauthUtils public static class OauthUtils
@ -20,4 +24,11 @@ public static class OauthUtils
return false; 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;
}
} }