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 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<T> RequestAsync<T>(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<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(); | ||||
|         return DeserializeObject<T>(bodyString) | ||||
|         return JsonConvert.DeserializeObject<T>(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<T>(string data) => JsonConvert.DeserializeObject<T>(data, _jsonSerializerSettings); | ||||
| } | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
|  | @ -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.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, | ||||
|  |  | |||
							
								
								
									
										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 scopes = req.Scope.Split(' '); | ||||
|         if (app.Scopes.Except(scopes).Any()) | ||||
|         if (scopes.Except(app.Scopes).Any()) | ||||
|         { | ||||
|             throw new ApiError.BadRequest("Invalid or unauthorized scopes"); | ||||
|         } | ||||
|  |  | |||
|  | @ -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<ErrorHandlerMiddleware>() | ||||
|             .AddScoped<AuthenticationMiddleware>() | ||||
|             .AddScoped<AuthorizationMiddleware>(); | ||||
|     } | ||||
|  | @ -14,6 +15,7 @@ public static class WebApplicationExtensions | |||
|     public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) | ||||
|     { | ||||
|         return app | ||||
|             .UseMiddleware<ErrorHandlerMiddleware>() | ||||
|             .UseMiddleware<AuthenticationMiddleware>() | ||||
|             .UseMiddleware<AuthorizationMiddleware>(); | ||||
|     } | ||||
|  |  | |||
|  | @ -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, | ||||
|  | @ -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, | ||||
							
								
								
									
										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.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 => | ||||
|  |  | |||
|  | @ -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; | ||||
|     } | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue