add error handler middleware
This commit is contained in:
parent
41e4dda7b4
commit
7a0247b551
13 changed files with 177 additions and 46 deletions
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
28
Foxchat.Core/Models/Http/ApiError.cs
Normal file
28
Foxchat.Core/Models/Http/ApiError.cs
Normal 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,
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
22
Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs
Normal file
22
Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs
Normal 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();
|
||||||
|
}
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
@ -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,
|
87
Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs
Normal file
87
Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs
Normal 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",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 =>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue