From 52e8ec5f170da21ca8d45f405f3d7fcc44410309 Mon Sep 17 00:00:00 2001 From: Frank Robijn Date: Sun, 6 Oct 2024 22:08:23 +0200 Subject: [PATCH 1/4] Internal server error if there are multiple matching callbacks, + .editorconfig --- .editorconfig | 213 ++++++++++++++++++ nanoFramework.WebServer/WebServer.cs | 87 ++++--- spelling_exclusion.dic | 1 + .../SimpleRouteController.cs | 22 +- ...ebServer E2E Tests.postman_collection.json | 31 +++ 5 files changed, 314 insertions(+), 40 deletions(-) create mode 100644 .editorconfig create mode 100644 spelling_exclusion.dic diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a5a9d5f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,213 @@ +# EditorConfig for Visual Studio 2022: https://learn.microsoft.com/en-us/visualstudio/ide/create-portable-custom-editor-options?view=vs-2022 + +# This is a top-most .editorconfig file +root = true + +#===================================================== +# +# nanoFramework specific settings +# +# +#===================================================== +[*] +# Generic EditorConfig settings +end_of_line = crlf +charset = utf-8-bom + +# Visual Studio spell checker +spelling_languages = en-us +spelling_checkable_types = strings,identifiers,comments +spelling_error_severity = information +spelling_exclusion_path = spelling_exclusion.dic + +#===================================================== +# +# Settings copied from the .NET runtime +# +# https://github.com/dotnet/runtime +# +#===================================================== +# Default settings: +# A newline ending every file +# Use 4 spaces as indentation +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +# Generated code +[*{_AssemblyInfo.cs,.notsupported.cs,AsmOffsets.cs}] +generated_code = true + +# C# files +[*.cs] +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion + +# avoid this. unless absolutely necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# Types: use keywords instead of BCL types, and permit var only when the type is clear +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = false:none +csharp_style_var_elsewhere = false:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# static fields should have s_ prefix +dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected +dotnet_naming_style.static_prefix_style.required_prefix = s_ +dotnet_naming_style.static_prefix_style.capitalization = camel_case + +# internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Code style defaults +csharp_using_directive_placement = outside_namespace:suggestion +dotnet_sort_system_directives_first = true +csharp_prefer_braces = true:silent +csharp_preserve_single_line_blocks = true:none +csharp_preserve_single_line_statements = false:none +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_simple_using_statement = false:none +csharp_style_prefer_switch_expression = true:suggestion +dotnet_style_readonly_field = true:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_collection_expression = when_types_exactly_match +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +csharp_prefer_simple_default_expression = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = true:silent +csharp_style_expression_bodied_constructors = true:silent +csharp_style_expression_bodied_operators = true:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = true:silent + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# Null checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Other features +csharp_style_prefer_index_operator = false:none +csharp_style_prefer_range_operator = false:none +csharp_style_pattern_local_over_anonymous_function = false:none + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# License header +file_header_template = Licensed to the .NET Foundation under one or more agreements.\nThe .NET Foundation licenses this file to you under the MIT license. + +# C++ Files +[*.{cpp,h,in}] +curly_bracket_next_line = true +indent_brace_style = Allman + +# Xml project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] +indent_size = 2 + +[*.{csproj,vbproj,proj,nativeproj,locproj}] +charset = utf-8 + +# Xml build files +[*.builds] +indent_size = 2 + +# Xml files +[*.{xml,stylecop,resx,ruleset}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +# YAML config files +[*.{yml,yaml}] +indent_size = 2 + +# Shell scripts +[*.sh] +end_of_line = lf +[*.{cmd,bat}] +end_of_line = crlf \ No newline at end of file diff --git a/nanoFramework.WebServer/WebServer.cs b/nanoFramework.WebServer/WebServer.cs index ea48b1d..495d401 100644 --- a/nanoFramework.WebServer/WebServer.cs +++ b/nanoFramework.WebServer/WebServer.cs @@ -1,7 +1,5 @@ -// -// Copyright (c) 2020 Laurent Ellerbach and the project contributors -// See LICENSE file in the project root for full license information. -// +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. using System; using System.Collections; @@ -15,7 +13,6 @@ using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading; - namespace nanoFramework.WebServer { /// @@ -408,7 +405,7 @@ public void Stop() _serverThread.Abort(); _serverThread = null; // Event is generate in the running thread - Debug.WriteLine("Stopped server in thread "); + Debug.WriteLine("Stopped server in thread "); } /// @@ -459,7 +456,7 @@ public static void SendFileOverHTTP(HttpListenerResponse response, string strFil byte[] buf = new byte[MaxSizeBuffer]; using FileStream dataReader = new FileStream(strFilePath, FileMode.Open, FileAccess.Read); - + long fileLength = dataReader.Length; response.ContentType = contentType; response.ContentLength64 = fileLength; @@ -472,7 +469,7 @@ public static void SendFileOverHTTP(HttpListenerResponse response, string strFil bytesToRead = bytesToRead < MaxSizeBuffer ? bytesToRead : MaxSizeBuffer; // Reads the data. - dataReader.Read(buf, 0,(int) bytesToRead); + dataReader.Read(buf, 0, (int)bytesToRead); // Writes data to browser response.OutputStream.Write(buf, 0, (int)bytesToRead); @@ -537,7 +534,8 @@ private void StartListener() // Variables used only within the "for". They are here for performance reasons bool mustAuthenticate; bool isAuthOk; - bool isRoute = false; + CallbackRoutes selectedRoute = null; + string multipleCallback = null; foreach (CallbackRoutes route in _callbackRoutes) { @@ -545,27 +543,60 @@ private void StartListener() { continue; } + if (selectedRoute is null) + { + selectedRoute = route; + } + else + { + multipleCallback ??= $"Multiple matching callbacks: {selectedRoute.Callback.DeclaringType.FullName}.{selectedRoute.Callback.Name}"; + multipleCallback += $", {route.Callback.DeclaringType.FullName}.{route.Callback.Name}"; + } + } - isRoute = true; + if (multipleCallback is not null) + { + multipleCallback += "."; + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + OutPutStream(context.Response, multipleCallback); + HandleContextResponse(context); + } + else if (selectedRoute is null) + { + if (CommandReceived != null) + { + // Starting a new thread to be able to handle a new request in parallel + CommandReceived.Invoke(this, new WebServerEventArgs(context)); + } + else + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + context.Response.ContentLength64 = 0; + } + + HandleContextResponse(context); + } + else + { // Check auth first - mustAuthenticate = route.Authentication != null && route.Authentication.AuthenticationType != AuthenticationType.None; + mustAuthenticate = selectedRoute.Authentication != null && selectedRoute.Authentication.AuthenticationType != AuthenticationType.None; isAuthOk = !mustAuthenticate; if (mustAuthenticate) { - if (route.Authentication.AuthenticationType == AuthenticationType.Basic) + if (selectedRoute.Authentication.AuthenticationType == AuthenticationType.Basic) { - var credSite = route.Authentication.Credentials ?? Credential; + var credSite = selectedRoute.Authentication.Credentials ?? Credential; var credReq = context.Request.Credentials; isAuthOk = credReq != null && credSite != null && (credSite.UserName == credReq.UserName) && (credSite.Password == credReq.Password); } - else if (route.Authentication.AuthenticationType == AuthenticationType.ApiKey) + else if (selectedRoute.Authentication.AuthenticationType == AuthenticationType.ApiKey) { - var apikeySite = route.Authentication.ApiKey ?? ApiKey; + var apikeySite = selectedRoute.Authentication.ApiKey ?? ApiKey; var apikeyReq = GetApiKeyFromHeaders(context.Request.Headers); isAuthOk = apikeyReq != null @@ -575,11 +606,11 @@ private void StartListener() if (!isAuthOk) { - if (route.Authentication != null && - route.Authentication.AuthenticationType == AuthenticationType.Basic) + if (selectedRoute.Authentication != null && + selectedRoute.Authentication.AuthenticationType == AuthenticationType.Basic) { context.Response.Headers.Add("WWW-Authenticate", - $"Basic realm=\"Access to {route.Route}\""); + $"Basic realm=\"Access to {selectedRoute.Route}\""); } context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; @@ -589,23 +620,7 @@ private void StartListener() return; } - InvokeRoute(route, context); - HandleContextResponse(context); - } - - if (!isRoute) - { - if (CommandReceived != null) - { - // Starting a new thread to be able to handle a new request in parallel - CommandReceived.Invoke(this, new WebServerEventArgs(context)); - } - else - { - context.Response.StatusCode = (int)HttpStatusCode.NotFound; - context.Response.ContentLength64 = 0; - } - + InvokeRoute(selectedRoute, context); HandleContextResponse(context); } }).Start(); @@ -621,7 +636,7 @@ private void StartListener() { // If we are here then set the server state to not running _cancel = true; - } + } WebServerStatusChanged?.Invoke(this, new WebServerStatusEventArgs(WebServerStatus.Stopped)); } diff --git a/spelling_exclusion.dic b/spelling_exclusion.dic new file mode 100644 index 0000000..8c0e1f8 --- /dev/null +++ b/spelling_exclusion.dic @@ -0,0 +1 @@ +nano diff --git a/tests/WebServerE2ETests/SimpleRouteController.cs b/tests/WebServerE2ETests/SimpleRouteController.cs index 1299b87..f1a1e5b 100644 --- a/tests/WebServerE2ETests/SimpleRouteController.cs +++ b/tests/WebServerE2ETests/SimpleRouteController.cs @@ -1,9 +1,9 @@ -// Copyright (c) 2020 Laurent Ellerbach and the project contributors -// See LICENSE file in the project root for full license information. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. -using nanoFramework.WebServer; using System.Diagnostics; using System.Net; +using nanoFramework.WebServer; namespace WebServerE2ETests { @@ -15,7 +15,7 @@ public void OutputWithOKCode(WebServerEventArgs e) Debug.WriteLine($"{nameof(OutputWithOKCode)} {e.Context.Request.HttpMethod} {e.Context.Request.RawUrl}"); WebServer.OutputHttpCode(e.Context.Response, HttpStatusCode.OK); } - + [Route("notfoundcode")] public void OutputWithNotFoundCode(WebServerEventArgs e) { @@ -45,5 +45,19 @@ public void RouteAnyTest(WebServerEventArgs e) { WebServer.OutputHttpCode(e.Context.Response, HttpStatusCode.OK); } + + [Route("multiplecallback")] + public void FirstOfMultipleCallback(WebServerEventArgs e) + { + Debug.WriteLine($"{nameof(FirstOfMultipleCallback)} {e.Context.Request.HttpMethod} {e.Context.Request.RawUrl}"); + WebServer.OutPutStream(e.Context.Response, nameof(FirstOfMultipleCallback)); + } + + [Route("multiplecallback")] + public void SecondOfMultipleCallback(WebServerEventArgs e) + { + Debug.WriteLine($"{nameof(SecondOfMultipleCallback)} {e.Context.Request.HttpMethod} {e.Context.Request.RawUrl}"); + WebServer.OutPutStream(e.Context.Response, nameof(SecondOfMultipleCallback)); + } } } diff --git a/tests/WebServerE2ETests/nanoFramework WebServer E2E Tests.postman_collection.json b/tests/WebServerE2ETests/nanoFramework WebServer E2E Tests.postman_collection.json index 29da8c2..226cedc 100644 --- a/tests/WebServerE2ETests/nanoFramework WebServer E2E Tests.postman_collection.json +++ b/tests/WebServerE2ETests/nanoFramework WebServer E2E Tests.postman_collection.json @@ -214,6 +214,37 @@ }, "response": [] }, + { + "name": "SimpleRouteController_MultipleCallback", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 500 and message\", function () {\r", + " pm.response.to.have.status(500);\r", + " pm.response.to.have.body(\"Multiple matching callbacks: WebServerE2ETests.SimpleRouteController.FirstOfMultipleCallback, WebServerE2ETests.SimpleRouteController.SecondOfMultipleCallback.\");\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/multiplecallback", + "host": [ + "{{base_url}}" + ], + "path": [ + "multiplecallback" + ] + } + }, + "response": [] + }, { "name": "SimpleRouteController_OutputWithNotFoundCode", "event": [ From cecf7275f3a8420f2268e20a4f0ac8fea7c27398 Mon Sep 17 00:00:00 2001 From: Frank Robijn Date: Mon, 7 Oct 2024 15:43:24 +0200 Subject: [PATCH 2/4] Multiple callbacks allowed to support different responses per authentication method/credentials. Error for multiple callbacks restricted to route-methods with the same authentication (method + credentials). --- nanoFramework.WebServer/WebServer.cs | 149 ++++-- tests/WebServerE2ETests/AuthController.cs | 6 +- tests/WebServerE2ETests/MixedController.cs | 119 +++++ tests/WebServerE2ETests/Program.cs | 12 +- .../WebServerE2ETests.nfproj | 1 + tests/WebServerE2ETests/WiFi.TEMPLATE.cs | 12 + ...ebServer E2E Tests.postman_collection.json | 502 ++++++++++++++++++ 7 files changed, 737 insertions(+), 64 deletions(-) create mode 100644 tests/WebServerE2ETests/MixedController.cs create mode 100644 tests/WebServerE2ETests/WiFi.TEMPLATE.cs diff --git a/nanoFramework.WebServer/WebServer.cs b/nanoFramework.WebServer/WebServer.cs index 495d401..b781f4f 100644 --- a/nanoFramework.WebServer/WebServer.cs +++ b/nanoFramework.WebServer/WebServer.cs @@ -531,11 +531,16 @@ private void StartListener() return; } + CallbackRoutes selectedRoute = null; + bool selectedRouteHasAuth = false; + string multipleCallback = null; + bool hasAuthRoutes = false; + string basicAuthNoCred = null; + bool authFailed = false; + // Variables used only within the "for". They are here for performance reasons bool mustAuthenticate; bool isAuthOk; - CallbackRoutes selectedRoute = null; - string multipleCallback = null; foreach (CallbackRoutes route in _callbackRoutes) { @@ -543,6 +548,72 @@ private void StartListener() { continue; } + + // Check auth first + mustAuthenticate = route.Authentication != null && route.Authentication.AuthenticationType != AuthenticationType.None; + if (mustAuthenticate) + { + hasAuthRoutes = true; + if (route.Authentication.AuthenticationType == AuthenticationType.Basic) + { + var credReq = context.Request.Credentials; + if (credReq is null) + { + if (basicAuthNoCred is null) + { + basicAuthNoCred = route.Route; + } + + continue; + } + + var credSite = route.Authentication.Credentials ?? Credential; + + isAuthOk = credSite != null + && (credSite.UserName == credReq.UserName) + && (credSite.Password == credReq.Password); + } + else if (route.Authentication.AuthenticationType == AuthenticationType.ApiKey) + { + var apikeyReq = GetApiKeyFromHeaders(context.Request.Headers); + if (apikeyReq is null) + { + continue; + } + + var apikeySite = route.Authentication.ApiKey ?? ApiKey; + + isAuthOk = apikeyReq == apikeySite; + } + else + { + isAuthOk = false; + } + + if (isAuthOk) + { + // This route can be used and has precedence over non-authenticated routes + if (!selectedRouteHasAuth) + { + selectedRoute = null; + multipleCallback = null; + } + + selectedRouteHasAuth = true; + } + else + { + authFailed = true; + continue; + } + } + else if (selectedRouteHasAuth || authFailed) + { + // The selected route has authentication and/or a route exists with failed authentication. + // Those have precedence over non-authenticated routes + continue; + } + if (selectedRoute is null) { selectedRoute = route; @@ -554,17 +625,21 @@ private void StartListener() } } - if (multipleCallback is not null) - { - multipleCallback += "."; - context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; - OutPutStream(context.Response, multipleCallback); - HandleContextResponse(context); - } - else if (selectedRoute is null) + if (selectedRoute is null) { - if (CommandReceived != null) + if (hasAuthRoutes) + { + if (!authFailed && basicAuthNoCred is not null) + { + context.Response.Headers.Add("WWW-Authenticate", + $"Basic realm=\"Access to {basicAuthNoCred}\""); + } + + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + context.Response.ContentLength64 = 0; + } + else if (CommandReceived != null) { // Starting a new thread to be able to handle a new request in parallel CommandReceived.Invoke(this, new WebServerEventArgs(context)); @@ -574,55 +649,19 @@ private void StartListener() context.Response.StatusCode = (int)HttpStatusCode.NotFound; context.Response.ContentLength64 = 0; } - - HandleContextResponse(context); + } + else if (multipleCallback is not null) + { + multipleCallback += "."; + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + OutPutStream(context.Response, multipleCallback); } else { - // Check auth first - mustAuthenticate = selectedRoute.Authentication != null && selectedRoute.Authentication.AuthenticationType != AuthenticationType.None; - isAuthOk = !mustAuthenticate; - - if (mustAuthenticate) - { - if (selectedRoute.Authentication.AuthenticationType == AuthenticationType.Basic) - { - var credSite = selectedRoute.Authentication.Credentials ?? Credential; - var credReq = context.Request.Credentials; - - isAuthOk = credReq != null && credSite != null - && (credSite.UserName == credReq.UserName) - && (credSite.Password == credReq.Password); - } - else if (selectedRoute.Authentication.AuthenticationType == AuthenticationType.ApiKey) - { - var apikeySite = selectedRoute.Authentication.ApiKey ?? ApiKey; - var apikeyReq = GetApiKeyFromHeaders(context.Request.Headers); - - isAuthOk = apikeyReq != null - && apikeyReq == apikeySite; - } - } - - if (!isAuthOk) - { - if (selectedRoute.Authentication != null && - selectedRoute.Authentication.AuthenticationType == AuthenticationType.Basic) - { - context.Response.Headers.Add("WWW-Authenticate", - $"Basic realm=\"Access to {selectedRoute.Route}\""); - } - - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - context.Response.ContentLength64 = 0; - - HandleContextResponse(context); - return; - } - InvokeRoute(selectedRoute, context); - HandleContextResponse(context); } + + HandleContextResponse(context); }).Start(); } diff --git a/tests/WebServerE2ETests/AuthController.cs b/tests/WebServerE2ETests/AuthController.cs index 8c76db3..5f6a3fa 100644 --- a/tests/WebServerE2ETests/AuthController.cs +++ b/tests/WebServerE2ETests/AuthController.cs @@ -1,8 +1,8 @@ -// Copyright (c) 2020 Laurent Ellerbach and the project contributors -// See LICENSE file in the project root for full license information. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. -using nanoFramework.WebServer; using System.Net; +using nanoFramework.WebServer; namespace WebServerE2ETests { diff --git a/tests/WebServerE2ETests/MixedController.cs b/tests/WebServerE2ETests/MixedController.cs new file mode 100644 index 0000000..1e2bcab --- /dev/null +++ b/tests/WebServerE2ETests/MixedController.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using nanoFramework.WebServer; + +namespace WebServerE2ETests +{ + class MixedController + { + #region ApiKey + public + [Route("authapikeyandpublic")] + [Authentication("ApiKey:superKey1234")] + public void ApiKeyAndPublicApiKey(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "ApiKey+Public: ApiKey"); + } + + [Route("authapikeyandpublic")] + public void ApiKeyAndPublicPublic(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "ApiKey+Public: Public"); + } + #endregion + + #region Basic + public + [Route("authbasicandpublic")] + [Authentication("Basic:user2 password")] + public void BasicAndPublicBasic(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "Basic+Public: Basic"); + } + + [Route("authbasicandpublic")] + public void BasicAndPublicPublic(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "Basic+Public: Public"); + } + #endregion + + #region Basic + ApiKey + Public + [Route("authapikeybasicandpublic")] + public void ApiKeyBasicAndPublicPublic(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "ApiKey+Basic+Public: Public"); + } + + [Route("authapikeybasicandpublic")] + [Authentication("Basic:user3 password")] + public void ApiKeyBasicAndPublicBasic3(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "ApiKey+Basic+Public: Basic user3"); + } + + [Route("authapikeybasicandpublic")] + [Authentication("Basic:user2 password")] + public void ApiKeyBasicAndPublicBasic2(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "ApiKey+Basic+Public: Basic user2"); + } + + [Authentication("ApiKey:superKey1234")] + [Route("authapikeybasicandpublic")] + public void ApiKeyBasicAndPublicApiKey(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "ApiKey+Basic+Public: ApiKey"); + } + + [Authentication("ApiKey:superKey42")] + [Route("authapikeybasicandpublic")] + public void ApiKeyBasicAndPublicApiKey2(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "ApiKey+Basic+Public: ApiKey 2"); + } + #endregion + + #region Multiple callbacks + [Route("authmultiple")] + public void MultiplePublic1(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "Multiple: Public1"); + } + + [Route("authmultiple")] + [Authentication("Basic:user2 password")] + public void MultipleBasic1(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "Multiple: Basic1"); + } + + [Route("authmultiple")] + public void MultiplePublic2(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "Multiple: Public2"); + } + + [Authentication("ApiKey:superKey1234")] + [Route("authmultiple")] + public void MultipleApiKey1(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "Multiple: ApiKey1"); + } + + [Route("authmultiple")] + [Authentication("Basic:user2 password")] + public void MultipleBasic2(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "Multiple: Basic2"); + } + + [Authentication("ApiKey:superKey1234")] + [Route("authmultiple")] + public void MultipleApiKey2(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "Multiple: ApiKey2"); + } + #endregion + } +} + diff --git a/tests/WebServerE2ETests/Program.cs b/tests/WebServerE2ETests/Program.cs index 290b58d..23dc8d7 100644 --- a/tests/WebServerE2ETests/Program.cs +++ b/tests/WebServerE2ETests/Program.cs @@ -1,8 +1,6 @@ -// Copyright (c) 2020 Laurent Ellerbach and the project contributors -// See LICENSE file in the project root for full license information. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. -using nanoFramework.Networking; -using nanoFramework.WebServer; using System; using System.Diagnostics; using System.IO; @@ -10,6 +8,8 @@ using System.Net.NetworkInformation; using System.Text; using System.Threading; +using nanoFramework.Networking; +using nanoFramework.WebServer; namespace WebServerE2ETests { @@ -29,7 +29,7 @@ public static void Main() } Debug.WriteLine($"Connected with wifi credentials. IP Address: {GetCurrentIPAddress()}"); - _server = new WebServer(80, HttpProtocol.Http, new Type[] { typeof(SimpleRouteController), typeof(AuthController) }); + _server = new WebServer(80, HttpProtocol.Http, new Type[] { typeof(SimpleRouteController), typeof(AuthController), typeof(MixedController) }); // To test authentication with various scenarios _server.ApiKey = "ATopSecretAPIKey1234"; _server.Credential = new NetworkCredential("topuser", "topPassword"); @@ -49,7 +49,7 @@ public static void Main() private static void WebServerStatusChanged(object obj, WebServerStatusEventArgs e) { - Debug.WriteLine($"The web server is now {(e.Status == WebServerStatus.Running ? "running" : "stopped" )}"); + Debug.WriteLine($"The web server is now {(e.Status == WebServerStatus.Running ? "running" : "stopped")}"); } private static void ServerCommandReceived(object obj, WebServerEventArgs e) diff --git a/tests/WebServerE2ETests/WebServerE2ETests.nfproj b/tests/WebServerE2ETests/WebServerE2ETests.nfproj index 210b962..4f79e1d 100644 --- a/tests/WebServerE2ETests/WebServerE2ETests.nfproj +++ b/tests/WebServerE2ETests/WebServerE2ETests.nfproj @@ -18,6 +18,7 @@ + diff --git a/tests/WebServerE2ETests/WiFi.TEMPLATE.cs b/tests/WebServerE2ETests/WiFi.TEMPLATE.cs new file mode 100644 index 0000000..1a13089 --- /dev/null +++ b/tests/WebServerE2ETests/WiFi.TEMPLATE.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace WebServerE2ETests +{ + public partial class Program + { + private const string Ssid = "yourSSID"; + private const string Password = "YourPassword"; + + } +} diff --git a/tests/WebServerE2ETests/nanoFramework WebServer E2E Tests.postman_collection.json b/tests/WebServerE2ETests/nanoFramework WebServer E2E Tests.postman_collection.json index 226cedc..6cde2f8 100644 --- a/tests/WebServerE2ETests/nanoFramework WebServer E2E Tests.postman_collection.json +++ b/tests/WebServerE2ETests/nanoFramework WebServer E2E Tests.postman_collection.json @@ -905,6 +905,498 @@ } }, "response": [] + }, + { + "name": "MixedController_ApiKeyPublic_ApiKey", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200 Body ApiKey\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.have.body(\"ApiKey+Public: ApiKey\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{auth_apikey}}", + "type": "string" + }, + { + "key": "key", + "value": "ApiKey", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authapikeyandpublic", + "host": [ + "{{base_url}}" + ], + "path": [ + "authapikeyandpublic" + ] + } + }, + "response": [] + }, + { + "name": "MixedController_ApiKeyPublic_Public", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200 Body Public\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.have.body(\"ApiKey+Public: Public\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authapikeyandpublic", + "host": [ + "{{base_url}}" + ], + "path": [ + "authapikeyandpublic" + ] + } + }, + "response": [] + }, + { + "name": "MixedController_BasicPublic_Basic", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200 Body Basic\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.have.body(\"Basic+Public: Basic\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{auth_basic_password}}", + "type": "string" + }, + { + "key": "username", + "value": "{{auth_basic_username}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authbasicandpublic", + "host": [ + "{{base_url}}" + ], + "path": [ + "authbasicandpublic" + ] + } + }, + "response": [] + }, + { + "name": "MixedController_BasicPublic_Public", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200 Body Public\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.have.body(\"Basic+Public: Public\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authbasicandpublic", + "host": [ + "{{base_url}}" + ], + "path": [ + "authbasicandpublic" + ] + } + }, + "response": [] + }, + { + "name": "MixedController_ApiKeyBasicPublic_ApiKey", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200 Body ApiKey\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.have.body(\"ApiKey+Basic+Public: ApiKey\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{auth_apikey}}", + "type": "string" + }, + { + "key": "key", + "value": "ApiKey", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authapikeybasicandpublic", + "host": [ + "{{base_url}}" + ], + "path": [ + "authapikeybasicandpublic" + ] + } + }, + "response": [] + }, + { + "name": "MixedController_ApiKeyBasicPublic_ApiKey2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200 Body ApiKey\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.have.body(\"ApiKey+Basic+Public: ApiKey 2\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{auth_apikey2}}", + "type": "string" + }, + { + "key": "key", + "value": "ApiKey", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authapikeybasicandpublic", + "host": [ + "{{base_url}}" + ], + "path": [ + "authapikeybasicandpublic" + ] + } + }, + "response": [] + }, + { + "name": "MixedController_ApiKeyBasicPublic_Basic", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200 Body Basic\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.have.body(\"ApiKey+Basic+Public: Basic user2\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{auth_basic_password}}", + "type": "string" + }, + { + "key": "username", + "value": "{{auth_basic_username}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authapikeybasicandpublic", + "host": [ + "{{base_url}}" + ], + "path": [ + "authapikeybasicandpublic" + ] + } + }, + "response": [] + }, + { + "name": "MixedController_ApiKeyBasicPublic_Basic2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200 Body Basic\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.have.body(\"ApiKey+Basic+Public: Basic user3\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{auth_basic_password}}", + "type": "string" + }, + { + "key": "username", + "value": "{{auth_basic_username2}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authapikeybasicandpublic", + "host": [ + "{{base_url}}" + ], + "path": [ + "authapikeybasicandpublic" + ] + } + }, + "response": [] + }, + { + "name": "MixedController_ApiKeyBasicPublic_Public", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200 Body Public\", function () {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.have.body(\"ApiKey+Basic+Public: Public\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authapikeybasicandpublic", + "host": [ + "{{base_url}}" + ], + "path": [ + "authapikeybasicandpublic" + ] + } + }, + "response": [] + }, + { + "name": "MixedController_MultipleCallbacks_ApiKey", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 500 Body ApiKey\", function () {\r", + " pm.response.to.have.status(500);\r", + " pm.response.to.have.body(\"Multiple matching callbacks: WebServerE2ETests.MixedController.MultipleApiKey1, WebServerE2ETests.MixedController.MultipleApiKey2.\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{auth_apikey}}", + "type": "string" + }, + { + "key": "key", + "value": "ApiKey", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authmultiple", + "host": [ + "{{base_url}}" + ], + "path": [ + "authmultiple" + ] + } + }, + "response": [] + }, + { + "name": "MixedController_MultipleCallbacks_Basic", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 500 Body Basic\", function () {\r", + " pm.response.to.have.status(500);\r", + " pm.response.to.have.body(\"Multiple matching callbacks: WebServerE2ETests.MixedController.MultipleBasic1, WebServerE2ETests.MixedController.MultipleBasic2.\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{auth_basic_password}}", + "type": "string" + }, + { + "key": "username", + "value": "{{auth_basic_username}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authmultiple", + "host": [ + "{{base_url}}" + ], + "path": [ + "authmultiple" + ] + } + }, + "response": [] + }, + { + "name": "MixedController_MultipleCallbacks_Public", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 500 Body Public\", function () {\r", + " pm.response.to.have.status(500);\r", + " pm.response.to.have.body(\"Multiple matching callbacks: WebServerE2ETests.MixedController.MultiplePublic1, WebServerE2ETests.MixedController.MultiplePublic2.\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/authmultiple", + "host": [ + "{{base_url}}" + ], + "path": [ + "authmultiple" + ] + } + }, + "response": [] } ], "event": [ @@ -937,6 +1429,11 @@ "value": "user2", "type": "string" }, + { + "key": "auth_basic_username2", + "value": "user3", + "type": "string" + }, { "key": "auth_basic_password", "value": "password", @@ -946,6 +1443,11 @@ "key": "auth_apikey", "value": "superKey1234", "type": "string" + }, + { + "key": "auth_apikey2", + "value": "superKey42", + "type": "string" } ] } \ No newline at end of file From d90a8a1ae200a08ef93dfb405a23dd02056666dc Mon Sep 17 00:00:00 2001 From: Frank Robijn Date: Mon, 7 Oct 2024 16:04:15 +0200 Subject: [PATCH 3/4] Documentation for the latest change --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/README.md b/README.md index 2acd84b..3cf072c 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,50 @@ With the previous example the following happens: All up, this is an example to show how to use authentication, it's been defined to allow flexibility. +The webserver supports having multiple authentication methods or credentials for the same route. Each pair of authentication method plus credentials should have its own method in the controller: + +```csharp +class MixedController +{ + + [Route("sameroute")] + [Authentication("Basic")] + public void Basic(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "sameroute: Basic"); + } + + [Authentication("ApiKey:superKey1234")] + [Route("sameroute")] + public void Key(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "sameroute: API key #1"); + } + + [Authentication("ApiKey:superKey5678")] + [Route("sameroute")] + public void Key2(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "sameroute: API key #2"); + } + + [Route("sameroute")] + public void None(WebServerEventArgs e) + { + WebServer.OutPutStream(e.Context.Response, "sameroute: Public"); + } +} +``` +The webserver selects the route for a request: + +- If there are no matching methods, a not-found response (404) is returned. +- If authentication information is passed in the header of the request, then only methods that require authentication are considered. If one of the method's credentials matches the credentials passed in the request, that method is called. Otherwise a non-authorized response (401) will be returned. +- If no authentication information is passed in the header of the request: + - If one of the methods does not require authentication, that method is called. + - Otherwise a non-authorized response (401) will be returned. If one of the methods requires basic authentication, the `WWW-Authenticate` header is included to request credentials. + +If two or more methods match the authentication method and credentials of the request, an internal server error is returned with a list of the methods. + ## Managing incoming queries thru events Very basic usage is the following: From c8588c32a3d040ffa7c72d418283714309826a3a Mon Sep 17 00:00:00 2001 From: Frank Robijn Date: Mon, 7 Oct 2024 16:32:34 +0200 Subject: [PATCH 4/4] WiFi.TEMPLATE.cs replaced by other mechanism. --- tests/WebServerE2ETests/WiFi.TEMPLATE.cs | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 tests/WebServerE2ETests/WiFi.TEMPLATE.cs diff --git a/tests/WebServerE2ETests/WiFi.TEMPLATE.cs b/tests/WebServerE2ETests/WiFi.TEMPLATE.cs deleted file mode 100644 index 1a13089..0000000 --- a/tests/WebServerE2ETests/WiFi.TEMPLATE.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace WebServerE2ETests -{ - public partial class Program - { - private const string Ssid = "yourSSID"; - private const string Password = "YourPassword"; - - } -}