Compare commits
17 Commits
2a33ecbf07
...
43edab2912
Author | SHA1 | Date | |
---|---|---|---|
43edab2912 | |||
dcdd43469b | |||
17fd260068 | |||
97187a8e45 | |||
cfe08dcf9b | |||
ae4d2073c4 | |||
269d976ad4 | |||
5fa545e981 | |||
2ab5dea8ba | |||
5e65aded79 | |||
dfac9ddca8 | |||
c66f3355ec | |||
c12323dc29 | |||
71c31c0bbb | |||
8c51ba83a4 | |||
9ff0f51e19 | |||
408a95e4b3 |
278
.editorconfig
Normal file
278
.editorconfig
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
# Удалите строку ниже, если вы хотите наследовать параметры .editorconfig из каталогов, расположенных выше в иерархии
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Файлы C#
|
||||||
|
[*.cs]
|
||||||
|
|
||||||
|
#### Основные параметры EditorConfig ####
|
||||||
|
|
||||||
|
# Отступы и интервалы
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
tab_width = 4
|
||||||
|
|
||||||
|
# Предпочтения для новых строк
|
||||||
|
end_of_line = crlf
|
||||||
|
insert_final_newline = false
|
||||||
|
|
||||||
|
#### Действия кода .NET ####
|
||||||
|
|
||||||
|
# Члены типа
|
||||||
|
dotnet_hide_advanced_members = false
|
||||||
|
dotnet_member_insertion_location = with_other_members_of_the_same_kind
|
||||||
|
dotnet_property_generation_behavior = prefer_throwing_properties
|
||||||
|
|
||||||
|
# Поиск символов
|
||||||
|
dotnet_search_reference_assemblies = true
|
||||||
|
|
||||||
|
#### Рекомендации по написанию кода .NET ####
|
||||||
|
|
||||||
|
# Упорядочение Using
|
||||||
|
dotnet_separate_import_directive_groups = false
|
||||||
|
dotnet_sort_system_directives_first = false
|
||||||
|
file_header_template = unset
|
||||||
|
|
||||||
|
# Предпочтения для this. и Me.
|
||||||
|
dotnet_style_qualification_for_event = false
|
||||||
|
dotnet_style_qualification_for_field = false
|
||||||
|
dotnet_style_qualification_for_method = false
|
||||||
|
dotnet_style_qualification_for_property = false
|
||||||
|
|
||||||
|
# Параметры использования ключевых слов языка и типов BCL
|
||||||
|
dotnet_style_predefined_type_for_locals_parameters_members = true
|
||||||
|
dotnet_style_predefined_type_for_member_access = true
|
||||||
|
|
||||||
|
# Предпочтения для скобок
|
||||||
|
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
|
||||||
|
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
|
||||||
|
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
|
||||||
|
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
|
||||||
|
|
||||||
|
# Предпочтения модификатора
|
||||||
|
dotnet_style_require_accessibility_modifiers = for_non_interface_members
|
||||||
|
|
||||||
|
# Выражения уровень предпочтения
|
||||||
|
dotnet_prefer_system_hash_code = true
|
||||||
|
dotnet_style_coalesce_expression = true
|
||||||
|
dotnet_style_collection_initializer = true
|
||||||
|
dotnet_style_explicit_tuple_names = true
|
||||||
|
dotnet_style_namespace_match_folder = true
|
||||||
|
dotnet_style_null_propagation = true
|
||||||
|
dotnet_style_object_initializer = true
|
||||||
|
dotnet_style_operator_placement_when_wrapping = beginning_of_line
|
||||||
|
dotnet_style_prefer_auto_properties = true
|
||||||
|
dotnet_style_prefer_collection_expression = when_types_loosely_match
|
||||||
|
dotnet_style_prefer_compound_assignment = true
|
||||||
|
dotnet_style_prefer_conditional_expression_over_assignment = true
|
||||||
|
dotnet_style_prefer_conditional_expression_over_return = true
|
||||||
|
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
|
||||||
|
dotnet_style_prefer_inferred_anonymous_type_member_names = true
|
||||||
|
dotnet_style_prefer_inferred_tuple_names = true
|
||||||
|
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
|
||||||
|
dotnet_style_prefer_simplified_boolean_expressions = true
|
||||||
|
dotnet_style_prefer_simplified_interpolation = true
|
||||||
|
|
||||||
|
# Предпочтения для полей
|
||||||
|
dotnet_style_readonly_field = true
|
||||||
|
|
||||||
|
# Настройки параметров
|
||||||
|
dotnet_code_quality_unused_parameters = non_public
|
||||||
|
|
||||||
|
# Параметры подавления
|
||||||
|
dotnet_remove_unnecessary_suppression_exclusions = none
|
||||||
|
|
||||||
|
# Предпочтения для новых строк
|
||||||
|
dotnet_style_allow_multiple_blank_lines_experimental = true
|
||||||
|
dotnet_style_allow_statement_immediately_after_block_experimental = false
|
||||||
|
|
||||||
|
#### Рекомендации по написанию кода C# ####
|
||||||
|
|
||||||
|
# Предпочтения var
|
||||||
|
csharp_style_var_elsewhere = true:suggestion
|
||||||
|
csharp_style_var_for_built_in_types = true:silent
|
||||||
|
csharp_style_var_when_type_is_apparent = true:silent
|
||||||
|
|
||||||
|
# Члены, заданные выражениями
|
||||||
|
csharp_style_expression_bodied_accessors = true:silent
|
||||||
|
csharp_style_expression_bodied_constructors = true:silent
|
||||||
|
csharp_style_expression_bodied_indexers = true:silent
|
||||||
|
csharp_style_expression_bodied_lambdas = true:silent
|
||||||
|
csharp_style_expression_bodied_local_functions = true:silent
|
||||||
|
csharp_style_expression_bodied_methods = true:silent
|
||||||
|
csharp_style_expression_bodied_operators = true:silent
|
||||||
|
csharp_style_expression_bodied_properties = true:silent
|
||||||
|
|
||||||
|
# Настройки соответствия шаблонов
|
||||||
|
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||||
|
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||||
|
csharp_style_prefer_extended_property_pattern = true:suggestion
|
||||||
|
csharp_style_prefer_not_pattern = true:suggestion
|
||||||
|
csharp_style_prefer_pattern_matching = true:silent
|
||||||
|
csharp_style_prefer_switch_expression = true:suggestion
|
||||||
|
|
||||||
|
# Настройки проверки на null
|
||||||
|
csharp_style_conditional_delegate_call = true:suggestion
|
||||||
|
|
||||||
|
# Предпочтения модификатора
|
||||||
|
csharp_prefer_static_anonymous_function = true:suggestion
|
||||||
|
csharp_prefer_static_local_function = true:suggestion
|
||||||
|
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
|
||||||
|
csharp_style_prefer_readonly_struct = true:suggestion
|
||||||
|
csharp_style_prefer_readonly_struct_member = true:suggestion
|
||||||
|
|
||||||
|
# Предпочтения для блоков кода
|
||||||
|
csharp_prefer_braces = when_multiline:silent
|
||||||
|
csharp_prefer_simple_using_statement = true:suggestion
|
||||||
|
csharp_prefer_system_threading_lock = true:suggestion
|
||||||
|
csharp_style_namespace_declarations = file_scoped:silent
|
||||||
|
csharp_style_prefer_method_group_conversion = true:silent
|
||||||
|
csharp_style_prefer_primary_constructors = true:suggestion
|
||||||
|
csharp_style_prefer_top_level_statements = false:silent
|
||||||
|
|
||||||
|
# Выражения уровень предпочтения
|
||||||
|
csharp_prefer_simple_default_expression = true:suggestion
|
||||||
|
csharp_style_deconstructed_variable_declaration = true:suggestion
|
||||||
|
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
|
||||||
|
csharp_style_inlined_variable_declaration = true:suggestion
|
||||||
|
csharp_style_prefer_index_operator = true:suggestion
|
||||||
|
csharp_style_prefer_local_over_anonymous_function = true:suggestion
|
||||||
|
csharp_style_prefer_null_check_over_type_check = true:suggestion
|
||||||
|
csharp_style_prefer_range_operator = true:suggestion
|
||||||
|
csharp_style_prefer_tuple_swap = true:suggestion
|
||||||
|
csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion
|
||||||
|
csharp_style_prefer_utf8_string_literals = true:suggestion
|
||||||
|
csharp_style_throw_expression = true:suggestion
|
||||||
|
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
|
||||||
|
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
|
||||||
|
|
||||||
|
# предпочтения для директивы using
|
||||||
|
csharp_using_directive_placement = outside_namespace:silent
|
||||||
|
|
||||||
|
# Предпочтения для новых строк
|
||||||
|
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
|
||||||
|
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
|
||||||
|
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
|
||||||
|
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:silent
|
||||||
|
csharp_style_allow_embedded_statements_on_same_line_experimental = false:silent
|
||||||
|
|
||||||
|
#### Правила форматирования C# ####
|
||||||
|
|
||||||
|
# Предпочтения для новых строк
|
||||||
|
csharp_new_line_before_catch = true
|
||||||
|
csharp_new_line_before_else = true
|
||||||
|
csharp_new_line_before_finally = true
|
||||||
|
csharp_new_line_before_members_in_anonymous_types = true
|
||||||
|
csharp_new_line_before_members_in_object_initializers = true
|
||||||
|
csharp_new_line_before_open_brace = all
|
||||||
|
csharp_new_line_between_query_expression_clauses = true
|
||||||
|
|
||||||
|
# Предпочтения для отступов
|
||||||
|
csharp_indent_block_contents = true
|
||||||
|
csharp_indent_braces = false
|
||||||
|
csharp_indent_case_contents = true
|
||||||
|
csharp_indent_case_contents_when_block = true
|
||||||
|
csharp_indent_labels = one_less_than_current
|
||||||
|
csharp_indent_switch_labels = true
|
||||||
|
|
||||||
|
# Предпочтения для интервалов
|
||||||
|
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 = false
|
||||||
|
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
|
||||||
|
|
||||||
|
# Предпочтения переноса
|
||||||
|
csharp_preserve_single_line_blocks = true
|
||||||
|
csharp_preserve_single_line_statements = true
|
||||||
|
|
||||||
|
#### Стили именования ####
|
||||||
|
|
||||||
|
# Правила именования
|
||||||
|
|
||||||
|
dotnet_naming_rule.interface_should_be_begins_with_i.severity = error
|
||||||
|
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
|
||||||
|
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
|
||||||
|
|
||||||
|
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = error
|
||||||
|
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
|
||||||
|
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
|
||||||
|
|
||||||
|
# Спецификации символов
|
||||||
|
|
||||||
|
dotnet_naming_symbols.interface.applicable_kinds = interface
|
||||||
|
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.interface.required_modifiers =
|
||||||
|
|
||||||
|
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
|
||||||
|
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.types.required_modifiers =
|
||||||
|
|
||||||
|
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
|
||||||
|
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.non_field_members.required_modifiers =
|
||||||
|
|
||||||
|
# Стили именования
|
||||||
|
|
||||||
|
dotnet_naming_style.pascal_case.required_prefix =
|
||||||
|
dotnet_naming_style.pascal_case.required_suffix =
|
||||||
|
dotnet_naming_style.pascal_case.word_separator =
|
||||||
|
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||||
|
|
||||||
|
dotnet_naming_style.begins_with_i.required_prefix = I
|
||||||
|
dotnet_naming_style.begins_with_i.required_suffix =
|
||||||
|
dotnet_naming_style.begins_with_i.word_separator =
|
||||||
|
dotnet_naming_style.begins_with_i.capitalization = pascal_case
|
||||||
|
|
||||||
|
[*.{cs,vb}]
|
||||||
|
dotnet_style_operator_placement_when_wrapping = beginning_of_line
|
||||||
|
tab_width = 4
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = crlf
|
||||||
|
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_auto_properties = true:silent
|
||||||
|
dotnet_style_object_initializer = true:suggestion
|
||||||
|
dotnet_style_collection_initializer = true:suggestion
|
||||||
|
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
|
||||||
|
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
|
||||||
|
dotnet_style_prefer_conditional_expression_over_return = true:silent
|
||||||
|
dotnet_style_explicit_tuple_names = true:suggestion
|
||||||
|
dotnet_style_prefer_inferred_tuple_names = true:suggestion
|
||||||
|
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
|
||||||
|
dotnet_style_prefer_compound_assignment = true:suggestion
|
||||||
|
dotnet_style_prefer_simplified_interpolation = true:suggestion
|
||||||
|
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
||||||
|
dotnet_style_namespace_match_folder = true:suggestion
|
||||||
|
dotnet_code_quality_unused_parameters = non_public:suggestion
|
||||||
|
dotnet_style_predefined_type_for_member_access = true:silent
|
||||||
|
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
|
||||||
|
dotnet_style_qualification_for_field = false:silent
|
||||||
|
dotnet_style_qualification_for_property = false:silent
|
||||||
|
dotnet_style_qualification_for_method = false:silent
|
||||||
|
dotnet_style_qualification_for_event = false:silent
|
||||||
|
dotnet_style_allow_multiple_blank_lines_experimental = true:silent
|
||||||
|
dotnet_style_allow_statement_immediately_after_block_experimental = false:silent
|
||||||
|
dotnet_style_readonly_field = true:suggestion
|
||||||
|
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
|
||||||
|
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
|
||||||
|
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
|
||||||
|
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
|
||||||
|
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
|
@ -11,17 +11,20 @@ public class CreateUserRequest
|
|||||||
/// Gets or sets the email address of the user.
|
/// Gets or sets the email address of the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
|
[EmailAddress]
|
||||||
public required string Email { get; set; }
|
public required string Email { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the username of the user.
|
/// Gets or sets the username of the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
|
[MinLength(2)]
|
||||||
public required string Username { get; set; }
|
public required string Username { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the password of the user.
|
/// Gets or sets the password of the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
|
[MinLength(2)]
|
||||||
public required string Password { get; set; }
|
public required string Password { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ EndProject
|
|||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Elements of the solution", "Elements of the solution", "{3E087889-A4A0-4A55-A07D-7D149A5BC928}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Elements of the solution", "Elements of the solution", "{3E087889-A4A0-4A55-A07D-7D149A5BC928}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
.dockerignore = .dockerignore
|
.dockerignore = .dockerignore
|
||||||
|
.editorconfig = .editorconfig
|
||||||
.env = .env
|
.env = .env
|
||||||
.gitattributes = .gitattributes
|
.gitattributes = .gitattributes
|
||||||
.gitignore = .gitignore
|
.gitignore = .gitignore
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace Mirea.Api.Endpoint.Common.Attributes;
|
namespace Mirea.Api.Endpoint.Common.Attributes;
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)]
|
||||||
public class CacheMaxAgeAttribute : Attribute
|
public class CacheMaxAgeAttribute : Attribute
|
||||||
{
|
{
|
||||||
public int MaxAge { get; }
|
public int MaxAge { get; }
|
||||||
|
@ -13,7 +13,7 @@ public class TokenAuthenticationAttribute : Attribute, IActionFilter
|
|||||||
public void OnActionExecuting(ActionExecutingContext context)
|
public void OnActionExecuting(ActionExecutingContext context)
|
||||||
{
|
{
|
||||||
var setupToken = context.HttpContext.RequestServices.GetRequiredService<ISetupToken>();
|
var setupToken = context.HttpContext.RequestServices.GetRequiredService<ISetupToken>();
|
||||||
if (!context.HttpContext.Request.Cookies.TryGetValue(AuthToken, out string? tokenFromCookie))
|
if (!context.HttpContext.Request.Cookies.TryGetValue(AuthToken, out var tokenFromCookie))
|
||||||
{
|
{
|
||||||
context.Result = new UnauthorizedResult();
|
context.Result = new UnauthorizedResult();
|
||||||
return;
|
return;
|
||||||
|
@ -3,6 +3,5 @@
|
|||||||
public interface IMaintenanceModeNotConfigureService
|
public interface IMaintenanceModeNotConfigureService
|
||||||
{
|
{
|
||||||
bool IsMaintenanceMode { get; }
|
bool IsMaintenanceMode { get; }
|
||||||
|
|
||||||
void DisableMaintenanceMode();
|
void DisableMaintenanceMode();
|
||||||
}
|
}
|
@ -3,8 +3,6 @@
|
|||||||
public interface IMaintenanceModeService
|
public interface IMaintenanceModeService
|
||||||
{
|
{
|
||||||
bool IsMaintenanceMode { get; }
|
bool IsMaintenanceMode { get; }
|
||||||
|
|
||||||
void EnableMaintenanceMode();
|
void EnableMaintenanceMode();
|
||||||
|
|
||||||
void DisableMaintenanceMode();
|
void DisableMaintenanceMode();
|
||||||
}
|
}
|
@ -17,7 +17,7 @@ public static class AvailableProvidersConverter
|
|||||||
_ => throw new ArgumentOutOfRangeException(nameof(provider), provider, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(provider), provider, null)
|
||||||
};
|
};
|
||||||
|
|
||||||
public static List<AvailableOAuthProvidersResponse> ConvertToDto(this (OAuthProvider Provider, Uri Redirect)[] data) =>
|
public static List<AvailableOAuthProvidersResponse> ConvertToDto(this IEnumerable<(OAuthProvider Provider, Uri Redirect)> data) =>
|
||||||
data.Select(x => new AvailableOAuthProvidersResponse()
|
data.Select(x => new AvailableOAuthProvidersResponse()
|
||||||
{
|
{
|
||||||
ProviderName = Enum.GetName(x.Provider)!,
|
ProviderName = Enum.GetName(x.Provider)!,
|
||||||
|
@ -9,5 +9,6 @@ public static class PairPeriodTimeConverter
|
|||||||
public static Dictionary<int, Dto.Common.PairPeriodTime> ConvertToDto(this IDictionary<int, ScheduleSettings.PairPeriodTime> pairPeriod) =>
|
public static Dictionary<int, Dto.Common.PairPeriodTime> ConvertToDto(this IDictionary<int, ScheduleSettings.PairPeriodTime> pairPeriod) =>
|
||||||
pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new Dto.Common.PairPeriodTime { Start = kvp.Value.Start, End = kvp.Value.End });
|
pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new Dto.Common.PairPeriodTime { Start = kvp.Value.Start, End = kvp.Value.End });
|
||||||
|
|
||||||
public static Dictionary<int, ScheduleSettings.PairPeriodTime> ConvertFromDto(this IDictionary<int, Dto.Common.PairPeriodTime> pairPeriod) => pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new ScheduleSettings.PairPeriodTime(kvp.Value.Start, kvp.Value.End));
|
public static Dictionary<int, ScheduleSettings.PairPeriodTime> ConvertFromDto(this IDictionary<int, Dto.Common.PairPeriodTime> pairPeriod) =>
|
||||||
|
pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new ScheduleSettings.PairPeriodTime(kvp.Value.Start, kvp.Value.End));
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,8 @@ namespace Mirea.Api.Endpoint.Common.Services.Security;
|
|||||||
|
|
||||||
public class DistributedCacheService(IDistributedCache cache) : ICacheService
|
public class DistributedCacheService(IDistributedCache cache) : ICacheService
|
||||||
{
|
{
|
||||||
public async Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default)
|
public async Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var options = new DistributedCacheEntryOptions
|
var options = new DistributedCacheEntryOptions
|
||||||
{
|
{
|
||||||
|
@ -9,7 +9,8 @@ namespace Mirea.Api.Endpoint.Common.Services.Security;
|
|||||||
|
|
||||||
public class MemoryCacheService(IMemoryCache cache) : ICacheService
|
public class MemoryCacheService(IMemoryCache cache) : ICacheService
|
||||||
{
|
{
|
||||||
public Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default)
|
public Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var options = new MemoryCacheEntryOptions
|
var options = new MemoryCacheEntryOptions
|
||||||
{
|
{
|
||||||
|
@ -32,7 +32,7 @@ public class ScheduleSyncService : IHostedService, IDisposable
|
|||||||
|
|
||||||
private void OnForceSyncRequested()
|
private void OnForceSyncRequested()
|
||||||
{
|
{
|
||||||
StopAsync(default).ContinueWith(_ =>
|
StopAsync(CancellationToken.None).ContinueWith(_ =>
|
||||||
{
|
{
|
||||||
_cancellationTokenSource = new CancellationTokenSource();
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
ExecuteTask(null);
|
ExecuteTask(null);
|
||||||
@ -41,9 +41,9 @@ public class ScheduleSyncService : IHostedService, IDisposable
|
|||||||
|
|
||||||
private void OnUpdateIntervalRequested()
|
private void OnUpdateIntervalRequested()
|
||||||
{
|
{
|
||||||
StopAsync(default).ContinueWith(_ =>
|
StopAsync(CancellationToken.None).ContinueWith(_ =>
|
||||||
{
|
{
|
||||||
StartAsync(default);
|
StartAsync(CancellationToken.None);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,7 +109,7 @@ public class ScheduleSyncService : IHostedService, IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
StopAsync(default).GetAwaiter().GetResult();
|
StopAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||||
_timer?.Dispose();
|
_timer?.Dispose();
|
||||||
ScheduleSyncManager.OnForceSyncRequested -= OnForceSyncRequested;
|
ScheduleSyncManager.OnForceSyncRequested -= OnForceSyncRequested;
|
||||||
ScheduleSyncManager.OnUpdateIntervalRequested -= OnUpdateIntervalRequested;
|
ScheduleSyncManager.OnUpdateIntervalRequested -= OnUpdateIntervalRequested;
|
||||||
|
@ -21,7 +21,7 @@ public static class EnvironmentConfiguration
|
|||||||
|
|
||||||
var commentIndex = line.IndexOf('#', StringComparison.Ordinal);
|
var commentIndex = line.IndexOf('#', StringComparison.Ordinal);
|
||||||
|
|
||||||
string arg = line;
|
var arg = line;
|
||||||
|
|
||||||
if (commentIndex != -1)
|
if (commentIndex != -1)
|
||||||
arg = arg.Remove(commentIndex, arg.Length - commentIndex);
|
arg = arg.Remove(commentIndex, arg.Length - commentIndex);
|
||||||
|
@ -19,12 +19,14 @@ public static class JwtConfiguration
|
|||||||
var jwtDecrypt = Encoding.UTF8.GetBytes(configuration["SECURITY_ENCRYPTION_TOKEN"] ?? string.Empty);
|
var jwtDecrypt = Encoding.UTF8.GetBytes(configuration["SECURITY_ENCRYPTION_TOKEN"] ?? string.Empty);
|
||||||
|
|
||||||
if (jwtDecrypt.Length != 32)
|
if (jwtDecrypt.Length != 32)
|
||||||
throw new InvalidOperationException("The secret token \"SECURITY_ENCRYPTION_TOKEN\" cannot be less than 32 characters long. Now the size is equal is " + jwtDecrypt.Length);
|
throw new InvalidOperationException("The secret token \"SECURITY_ENCRYPTION_TOKEN\" cannot be less than 32 characters long. " +
|
||||||
|
"Now the size is equal is " + jwtDecrypt.Length);
|
||||||
|
|
||||||
var jwtKey = Encoding.UTF8.GetBytes(configuration["SECURITY_SIGNING_TOKEN"] ?? string.Empty);
|
var jwtKey = Encoding.UTF8.GetBytes(configuration["SECURITY_SIGNING_TOKEN"] ?? string.Empty);
|
||||||
|
|
||||||
if (jwtKey.Length != 64)
|
if (jwtKey.Length != 64)
|
||||||
throw new InvalidOperationException("The signature token \"SECURITY_SIGNING_TOKEN\" cannot be less than 64 characters. Now the size is " + jwtKey.Length);
|
throw new InvalidOperationException("The signature token \"SECURITY_SIGNING_TOKEN\" cannot be less than 64 characters. " +
|
||||||
|
"Now the size is " + jwtKey.Length);
|
||||||
|
|
||||||
var jwtIssuer = configuration["SECURITY_JWT_ISSUER"];
|
var jwtIssuer = configuration["SECURITY_JWT_ISSUER"];
|
||||||
var jwtAudience = configuration["SECURITY_JWT_AUDIENCE"];
|
var jwtAudience = configuration["SECURITY_JWT_AUDIENCE"];
|
||||||
|
@ -19,6 +19,7 @@ public static class SwaggerConfiguration
|
|||||||
{
|
{
|
||||||
options.SchemaFilter<SwaggerExampleFilter>();
|
options.SchemaFilter<SwaggerExampleFilter>();
|
||||||
options.OperationFilter<SwaggerDefaultValues>();
|
options.OperationFilter<SwaggerDefaultValues>();
|
||||||
|
options.OperationFilter<ActionResultSchemaFilter>();
|
||||||
var basePath = AppDomain.CurrentDomain.BaseDirectory;
|
var basePath = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
|
|
||||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||||
|
@ -10,12 +10,22 @@ namespace Mirea.Api.Endpoint.Configuration.Model;
|
|||||||
public class Admin : ISaveSettings
|
public class Admin : ISaveSettings
|
||||||
{
|
{
|
||||||
[JsonIgnore] private const string FileName = "admin.json";
|
[JsonIgnore] private const string FileName = "admin.json";
|
||||||
|
private string _username = string.Empty;
|
||||||
|
private string _email = string.Empty;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public static string FilePath => PathBuilder.Combine(FileName);
|
public static string FilePath => PathBuilder.Combine(FileName);
|
||||||
|
|
||||||
public required string Username { get; set; }
|
public required string Username
|
||||||
public required string Email { get; set; }
|
{
|
||||||
|
get => _username;
|
||||||
|
set => _username = value.Trim();
|
||||||
|
}
|
||||||
|
public required string Email
|
||||||
|
{
|
||||||
|
get => _email;
|
||||||
|
set => _email = value.Trim();
|
||||||
|
}
|
||||||
public required string PasswordHash { get; set; }
|
public required string PasswordHash { get; set; }
|
||||||
public required string Salt { get; set; }
|
public required string Salt { get; set; }
|
||||||
public TwoFactorAuthenticator TwoFactorAuthenticator { get; set; } = TwoFactorAuthenticator.None;
|
public TwoFactorAuthenticator TwoFactorAuthenticator { get; set; } = TwoFactorAuthenticator.None;
|
||||||
|
@ -0,0 +1,81 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
|
||||||
|
|
||||||
|
public class ActionResultSchemaFilter : IOperationFilter
|
||||||
|
{
|
||||||
|
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||||
|
{
|
||||||
|
var returnType = context.MethodInfo.ReturnType;
|
||||||
|
if (!returnType.IsEquivalentTo(typeof(ActionResult)) &&
|
||||||
|
!returnType.IsEquivalentTo(typeof(ContentResult)) &&
|
||||||
|
!returnType.IsEquivalentTo(typeof(FileStreamResult)) &&
|
||||||
|
!returnType.IsGenericType)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (returnType.IsGenericType &&
|
||||||
|
!returnType.GetGenericTypeDefinition().IsEquivalentTo(typeof(ActionResult<>)) &&
|
||||||
|
!returnType.GetGenericTypeDefinition().IsEquivalentTo(typeof(Task<>)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var genericType = returnType.IsGenericType ? returnType.GetGenericArguments().FirstOrDefault() : returnType;
|
||||||
|
if (genericType == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var responseTypeAttributes = context.MethodInfo.GetCustomAttributes(typeof(ProducesResponseTypeAttribute), false)
|
||||||
|
.Cast<ProducesResponseTypeAttribute>()
|
||||||
|
.Where(attr => attr.StatusCode == 200)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var contentType = "application/json";
|
||||||
|
|
||||||
|
if (context.MethodInfo.GetCustomAttributes(typeof(ProducesAttribute), false)
|
||||||
|
.FirstOrDefault() is ProducesAttribute producesAttribute)
|
||||||
|
contentType = producesAttribute.ContentTypes.FirstOrDefault() ?? "application/json";
|
||||||
|
|
||||||
|
if (responseTypeAttributes.Count != 0)
|
||||||
|
{
|
||||||
|
var responseType = responseTypeAttributes.First().Type;
|
||||||
|
genericType = responseType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (genericType.IsEquivalentTo(typeof(ContentResult)) || genericType.IsEquivalentTo(typeof(FileStreamResult)))
|
||||||
|
{
|
||||||
|
operation.Responses["200"] = new OpenApiResponse
|
||||||
|
{
|
||||||
|
Description = "OK",
|
||||||
|
Content = new Dictionary<string, OpenApiMediaType>
|
||||||
|
{
|
||||||
|
[contentType] = new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (genericType == typeof(ActionResult))
|
||||||
|
{
|
||||||
|
operation.Responses["200"] = new OpenApiResponse { Description = "OK" };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
OpenApiSchema schema;
|
||||||
|
if (genericType.IsGenericType && genericType.GetGenericTypeDefinition() == typeof(ActionResult<>))
|
||||||
|
schema = context.SchemaGenerator.GenerateSchema(genericType.GetGenericArguments().FirstOrDefault(),
|
||||||
|
context.SchemaRepository);
|
||||||
|
else
|
||||||
|
schema = context.SchemaGenerator.GenerateSchema(genericType, context.SchemaRepository);
|
||||||
|
|
||||||
|
operation.Responses["200"] = new OpenApiResponse
|
||||||
|
{
|
||||||
|
Description = "OK",
|
||||||
|
Content = new Dictionary<string, OpenApiMediaType>
|
||||||
|
{
|
||||||
|
[contentType] = new() { Schema = schema }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -23,7 +23,8 @@ public class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) :
|
|||||||
{
|
{
|
||||||
Title = "MIREA Schedule Web API",
|
Title = "MIREA Schedule Web API",
|
||||||
Version = description.ApiVersion.ToString(),
|
Version = description.ApiVersion.ToString(),
|
||||||
Description = "This API provides a convenient interface for retrieving data stored in the database. Special attention was paid to the lightweight and easy transfer of all necessary data. Made by the Winsomnia team.",
|
Description = "This API provides a convenient interface for retrieving data stored in the database. " +
|
||||||
|
"Special attention was paid to the lightweight and easy transfer of all necessary data. Made by the Winsomnia team.",
|
||||||
Contact = new OpenApiContact { Name = "Author name", Email = "support@winsomnia.net" },
|
Contact = new OpenApiContact { Name = "Author name", Email = "support@winsomnia.net" },
|
||||||
License = new OpenApiLicense { Name = "MIT", Url = new Uri("https://opensource.org/licenses/MIT") }
|
License = new OpenApiLicense { Name = "MIT", Url = new Uri("https://opensource.org/licenses/MIT") }
|
||||||
};
|
};
|
||||||
|
@ -14,8 +14,8 @@ public class SetupTokenService : ISetupToken
|
|||||||
|
|
||||||
var token2 = Token.Value.Span;
|
var token2 = Token.Value.Span;
|
||||||
|
|
||||||
int result = 0;
|
var result = 0;
|
||||||
for (int i = 0; i < Token.Value.Length; i++)
|
for (var i = 0; i < Token.Value.Length; i++)
|
||||||
result |= token2[i] ^ token[i];
|
result |= token2[i] ^ token[i];
|
||||||
|
|
||||||
return result == 0;
|
return result == 0;
|
||||||
|
@ -29,7 +29,6 @@ using System.Collections.Generic;
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Mail;
|
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Security;
|
using System.Security;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
@ -144,7 +143,7 @@ public class SetupController(
|
|||||||
[BadRequestResponse]
|
[BadRequestResponse]
|
||||||
public ActionResult<bool> SetPsql([FromBody] DatabaseRequest request)
|
public ActionResult<bool> SetPsql([FromBody] DatabaseRequest request)
|
||||||
{
|
{
|
||||||
string connectionString = $"Host={request.Server}:{request.Port};Username={request.User};Database={request.Database}";
|
var connectionString = $"Host={request.Server}:{request.Port};Username={request.User};Database={request.Database}";
|
||||||
if (request.Password != null)
|
if (request.Password != null)
|
||||||
connectionString += $";Password={request.Password}";
|
connectionString += $";Password={request.Password}";
|
||||||
if (request.Ssl)
|
if (request.Ssl)
|
||||||
@ -171,7 +170,7 @@ public class SetupController(
|
|||||||
[BadRequestResponse]
|
[BadRequestResponse]
|
||||||
public ActionResult<bool> SetMysql([FromBody] DatabaseRequest request)
|
public ActionResult<bool> SetMysql([FromBody] DatabaseRequest request)
|
||||||
{
|
{
|
||||||
string connectionString = $"Server={request.Server}:{request.Port};Uid={request.User};Database={request.Database};";
|
var connectionString = $"Server={request.Server}:{request.Port};Uid={request.User};Database={request.Database};";
|
||||||
if (request.Password != null)
|
if (request.Password != null)
|
||||||
connectionString += $"Pwd={request.Password};";
|
connectionString += $"Pwd={request.Password};";
|
||||||
if (request.Ssl)
|
if (request.Ssl)
|
||||||
@ -241,7 +240,7 @@ public class SetupController(
|
|||||||
[BadRequestResponse]
|
[BadRequestResponse]
|
||||||
public ActionResult<bool> SetRedis([FromBody] CacheRequest request)
|
public ActionResult<bool> SetRedis([FromBody] CacheRequest request)
|
||||||
{
|
{
|
||||||
string connectionString = $"{request.Server}:{request.Port},ssl=false";
|
var connectionString = $"{request.Server}:{request.Port},ssl=false";
|
||||||
if (request.Password != null)
|
if (request.Password != null)
|
||||||
connectionString += $",password={request.Password}";
|
connectionString += $",password={request.Password}";
|
||||||
|
|
||||||
@ -305,10 +304,6 @@ public class SetupController(
|
|||||||
public ActionResult<string> CreateAdmin([FromBody] CreateUserRequest userRequest)
|
public ActionResult<string> CreateAdmin([FromBody] CreateUserRequest userRequest)
|
||||||
{
|
{
|
||||||
new PasswordPolicyService(GeneralConfig.PasswordPolicy).ValidatePasswordOrThrow(userRequest.Password);
|
new PasswordPolicyService(GeneralConfig.PasswordPolicy).ValidatePasswordOrThrow(userRequest.Password);
|
||||||
|
|
||||||
if (!MailAddress.TryCreate(userRequest.Email, out _))
|
|
||||||
throw new ControllerArgumentException("The email address is incorrect.");
|
|
||||||
|
|
||||||
var (salt, hash) = passwordHashService.HashPassword(userRequest.Password);
|
var (salt, hash) = passwordHashService.HashPassword(userRequest.Password);
|
||||||
|
|
||||||
var admin = new Admin
|
var admin = new Admin
|
||||||
|
@ -16,14 +16,15 @@ using Mirea.Api.Security.Services;
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Security.Claims;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
|
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
|
||||||
|
|
||||||
namespace Mirea.Api.Endpoint.Controllers.V1;
|
namespace Mirea.Api.Endpoint.Controllers.V1;
|
||||||
|
|
||||||
[ApiVersion("1.0")]
|
[ApiVersion("1.0")]
|
||||||
public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<GeneralConfig> generalConfig, AuthService auth, PasswordHashService passwordService, OAuthService oAuthService) : BaseController
|
public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<GeneralConfig> generalConfig, AuthService auth,
|
||||||
|
PasswordHashService passwordService, OAuthService oAuthService) : BaseController
|
||||||
{
|
{
|
||||||
private CookieOptionsParameters GetCookieParams() =>
|
private CookieOptionsParameters GetCookieParams() =>
|
||||||
new()
|
new()
|
||||||
@ -32,94 +33,73 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
|
|||||||
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
|
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string GenerateHtmlResponse(string title, string message, OAuthProvider? provider, bool isError = false, string? traceId = null)
|
private static string GenerateHtmlResponse(
|
||||||
|
string title,
|
||||||
|
string message,
|
||||||
|
Uri? callback,
|
||||||
|
string traceId,
|
||||||
|
bool isError)
|
||||||
{
|
{
|
||||||
string messageColor = isError ? "red" : "white";
|
var callbackUrl = callback?.ToString();
|
||||||
string script = "<script>setTimeout(()=>{if(window.opener){window.opener.postMessage(" +
|
|
||||||
"{success:" + (!isError).ToString().ToLower() +
|
|
||||||
",provider:'" + (provider == null ? "null" : (int)provider) +
|
|
||||||
"',providerName:'" + (provider == null ? "null" : Enum.GetName(provider.Value)) +
|
|
||||||
"',message:'" + message.Replace("'", "\\'") +
|
|
||||||
"'},'*');}window.close();}, 15000);</script>";
|
|
||||||
|
|
||||||
return $"<!DOCTYPE html><html lang=ru><head><meta charset=UTF-8><meta content=\"width=device-width,initial-scale=1\"name=viewport><link href=\"https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap\"rel=stylesheet><style>body{{background-color:#121212;color:#fff;font-family:Roboto,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;text-align:center}}.container{{max-width:600px;padding:20px;border-radius:8px;background-color:#1e1e1e;box-shadow:0 4px 20px rgba(0,0,0,.5)}}h1{{font-size:24px;margin-bottom:20px}}p{{font-size:16px;color:{messageColor}}}</style><title>{title}</title></head><body><div class=container><h1>{title}</h1><p>{message}</p><p style=font-size:14px;color:silver;>Это информационная страница. Вы можете закрыть её.</p>{(!string.IsNullOrEmpty(traceId) ? $"<code style=font-size:12px;color:gray;>TraceId={traceId}</code>" : string.Empty)}</div>{script}</body></html>";
|
var script = callback == null ? string.Empty :
|
||||||
|
$"<script>setTimeout(()=>{{window.location.href='{callbackUrl}';}}, {(isError ? 15000 : 5000)});</script>";
|
||||||
|
|
||||||
|
var blockInfo = "<p>" + (callback == null ?
|
||||||
|
"Вернитесь обратно и попробуйте снова позже.</p>" :
|
||||||
|
$"Если вы не будете автоматически перенаправлены, нажмите ниже.</p>" +
|
||||||
|
$"<a href=\"{callbackUrl}\" style=\"color:inherit;text-decoration:underline;\">Перейти вручную</a>");
|
||||||
|
|
||||||
|
return $"<!DOCTYPE html><html lang=ru><head><meta charset=UTF-8><meta content=\"width=device-width,initial-scale=1\"name=viewport><link href=\"https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap\"rel=stylesheet><style>body{{background-color:#121212;color:#fff;font-family:Roboto,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;text-align:center}}.container{{max-width:600px;padding:20px;border-radius:8px;background-color:#1e1e1e;box-shadow:0 4px 20px rgba(0,0,0,.5)}}h1{{font-size:24px;margin-bottom:20px}}</style><title>{title}</title></head><body><div class=container><h1>{title}</h1>{blockInfo}<p style=font-size:14px;color:silver;>{message}</p><code style=font-size:12px;color:gray;>TraceId={traceId}</code></div>{script}</body></html>";
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("OAuth2")]
|
[HttpGet("OAuth2")]
|
||||||
[BadRequestResponse]
|
[BadRequestResponse]
|
||||||
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
|
|
||||||
[Produces("text/html")]
|
[Produces("text/html")]
|
||||||
[MaintenanceModeIgnore]
|
[MaintenanceModeIgnore]
|
||||||
public async Task<ContentResult> OAuth2([FromQuery] string code, [FromQuery] string state)
|
public async Task<ContentResult> OAuth2([FromQuery] string? code, [FromQuery] string? state)
|
||||||
{
|
{
|
||||||
var userId = HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
string title;
|
|
||||||
string message;
|
|
||||||
OAuthProvider provider;
|
|
||||||
OAuthUser oAuthUser;
|
|
||||||
var traceId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
var traceId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
||||||
|
|
||||||
try
|
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
||||||
{
|
return Content(GenerateHtmlResponse(
|
||||||
(provider, oAuthUser) = await oAuthService.LoginOAuth(HttpContext, GetCookieParams(),
|
"Ошибка передачи данных!",
|
||||||
|
"Провайдер OAuth не передал нужных данных.",
|
||||||
|
null,
|
||||||
|
traceId,
|
||||||
|
true), "text/html");
|
||||||
|
|
||||||
|
var result = await oAuthService.LoginOAuth(HttpContext, GetCookieParams(),
|
||||||
HttpContext.GetApiUrl(Url.Action("OAuth2")!), code, state);
|
HttpContext.GetApiUrl(Url.Action("OAuth2")!), code, state);
|
||||||
}
|
|
||||||
catch (Exception e)
|
string? callbackUrl = null;
|
||||||
|
|
||||||
|
if (result.Callback != null)
|
||||||
|
callbackUrl = result.Callback + (result.Callback.Query.Length > 0 ? "&" : "?") +
|
||||||
|
$"result={Uri.EscapeDataString(result.Token)}";
|
||||||
|
|
||||||
|
string title, message;
|
||||||
|
|
||||||
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
title = "Произошла ошибка при общении с провайдером OAuth!";
|
if (callbackUrl != null)
|
||||||
message = e.Message;
|
callbackUrl += $"&traceId={Uri.EscapeDataString(traceId)}";
|
||||||
return Content(GenerateHtmlResponse(title, message, null, true, traceId), "text/html");
|
|
||||||
|
title = "Ошибка авторизации!";
|
||||||
|
message = result.ErrorMessage ?? "Произошла ошибка. Попробуйте ещё раз.";
|
||||||
}
|
}
|
||||||
|
else
|
||||||
var userEntity = user.Value;
|
|
||||||
|
|
||||||
if (userId != null)
|
|
||||||
{
|
{
|
||||||
userEntity.OAuthProviders ??= [];
|
title = "Авторизация завершена!";
|
||||||
|
message = "Вы будете перенаправлены обратно через несколько секунд.";
|
||||||
if (!userEntity.OAuthProviders.TryAdd(provider, oAuthUser))
|
|
||||||
{
|
|
||||||
title = "Ошибка связи аккаунта!";
|
|
||||||
message = "Этот OAuth провайдер уже связан с вашей учетной записью. Пожалуйста, используйте другого провайдера или удалите связь с аккаунтом.";
|
|
||||||
return Content(GenerateHtmlResponse(title, message, provider, true, traceId), "text/html");
|
|
||||||
}
|
|
||||||
|
|
||||||
userEntity.SaveSetting();
|
|
||||||
|
|
||||||
title = "Учетная запись успешно связана.";
|
|
||||||
message = "Вы успешно связали свою учетную запись с провайдером OAuth. Вы можете продолжить использовать приложение.";
|
|
||||||
return Content(GenerateHtmlResponse(title, message, provider), "text/html");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userEntity.OAuthProviders != null &&
|
return Content(GenerateHtmlResponse(
|
||||||
userEntity.OAuthProviders.TryGetValue(provider, out var userOAuth) &&
|
title,
|
||||||
userOAuth.Id == oAuthUser.Id)
|
message,
|
||||||
{
|
callbackUrl == null ? null : new Uri(callbackUrl),
|
||||||
await auth.LoginOAuthAsync(GetCookieParams(), HttpContext, new User
|
traceId,
|
||||||
{
|
!result.Success), "text/html");
|
||||||
Id = 1.ToString(),
|
|
||||||
Username = userEntity.Username,
|
|
||||||
Email = userEntity.Email,
|
|
||||||
PasswordHash = userEntity.PasswordHash,
|
|
||||||
Salt = userEntity.Salt,
|
|
||||||
TwoFactorAuthenticator = userEntity.TwoFactorAuthenticator,
|
|
||||||
SecondFactorToken = userEntity.Secret,
|
|
||||||
OAuthProviders = userEntity.OAuthProviders
|
|
||||||
});
|
|
||||||
|
|
||||||
title = "Успешный вход в аккаунт.";
|
|
||||||
message = "Вы успешно вошли в свою учетную запись. Добро пожаловать!";
|
|
||||||
return Content(GenerateHtmlResponse(title, message, provider), "text/html");
|
|
||||||
}
|
|
||||||
|
|
||||||
title = "Вы успешно зарегистрированы.";
|
|
||||||
message = "Процесс завершен. Вы можете закрыть эту страницу.";
|
|
||||||
userEntity.Email = string.IsNullOrEmpty(oAuthUser.Email) ? string.Empty : oAuthUser.Email;
|
|
||||||
userEntity.Username = string.IsNullOrEmpty(oAuthUser.Username) ? string.Empty : oAuthUser.Username;
|
|
||||||
userEntity.OAuthProviders ??= [];
|
|
||||||
userEntity.OAuthProviders.Add(provider, oAuthUser);
|
|
||||||
userEntity.SaveSetting();
|
|
||||||
return Content(GenerateHtmlResponse(title, message, provider), "text/html");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -129,16 +109,23 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
|
|||||||
/// This method generates a redirect URL for the selected provider and redirects the user to it.
|
/// This method generates a redirect URL for the selected provider and redirects the user to it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="provider">The identifier of the OAuth provider to authorize with.</param>
|
/// <param name="provider">The identifier of the OAuth provider to authorize with.</param>
|
||||||
|
/// <param name="callback">The address where the user will need to be redirected after the end of communication with the OAuth provider</param>
|
||||||
/// <returns>A redirect to the OAuth provider's authorization URL.</returns>
|
/// <returns>A redirect to the OAuth provider's authorization URL.</returns>
|
||||||
/// <exception cref="ControllerArgumentException">Thrown if the specified provider is not valid.</exception>
|
/// <exception cref="ControllerArgumentException">Thrown if the specified provider is not valid.</exception>
|
||||||
[HttpGet("AuthorizeOAuth2")]
|
[HttpGet("AuthorizeOAuth2")]
|
||||||
[MaintenanceModeIgnore]
|
[MaintenanceModeIgnore]
|
||||||
public ActionResult AuthorizeOAuth2([FromQuery] int provider)
|
public ActionResult AuthorizeOAuth2([FromQuery] int provider, [FromQuery] Uri callback)
|
||||||
{
|
{
|
||||||
if (!Enum.IsDefined(typeof(OAuthProvider), provider))
|
if (!Enum.IsDefined(typeof(OAuthProvider), provider))
|
||||||
throw new ControllerArgumentException("There is no selected provider");
|
throw new ControllerArgumentException("There is no selected provider");
|
||||||
|
|
||||||
return Redirect(oAuthService.GetProviderRedirect(HttpContext, GetCookieParams(), HttpContext.GetApiUrl(Url.Action("OAuth2")!), (OAuthProvider)provider).AbsoluteUri);
|
if (!callback.IsAbsoluteUri)
|
||||||
|
throw new ControllerArgumentException("The callback URL must be absolute.");
|
||||||
|
|
||||||
|
return Redirect(oAuthService.GetProviderRedirect(HttpContext, GetCookieParams(),
|
||||||
|
HttpContext.GetApiUrl(Url.Action("OAuth2")!),
|
||||||
|
(OAuthProvider)provider,
|
||||||
|
callback).AbsoluteUri);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -150,9 +137,17 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
|
|||||||
/// <returns>A list of available providers and their redirect URLs.</returns>
|
/// <returns>A list of available providers and their redirect URLs.</returns>
|
||||||
[HttpGet("AvailableProviders")]
|
[HttpGet("AvailableProviders")]
|
||||||
[MaintenanceModeIgnore]
|
[MaintenanceModeIgnore]
|
||||||
public ActionResult<List<AvailableOAuthProvidersResponse>> AvailableProviders() =>
|
public ActionResult<List<AvailableOAuthProvidersResponse>> AvailableProviders([FromQuery] Uri callback) =>
|
||||||
Ok(oAuthService
|
Ok(oAuthService
|
||||||
.GetAvailableProviders(HttpContext.GetApiUrl(Url.Action("AuthorizeOAuth2")!))
|
.GetAvailableProviders(HttpContext.GetApiUrl(Url.Action("AuthorizeOAuth2")!))
|
||||||
|
.Select(x =>
|
||||||
|
{
|
||||||
|
if (!callback.IsAbsoluteUri)
|
||||||
|
throw new ControllerArgumentException("The callback URL must be absolute.");
|
||||||
|
|
||||||
|
x.Redirect = new Uri(x.Redirect + "&callback=" + Uri.EscapeDataString(callback.AbsoluteUri));
|
||||||
|
return x;
|
||||||
|
})
|
||||||
.ConvertToDto());
|
.ConvertToDto());
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -24,7 +24,8 @@ public class DisciplineController(IMediator mediator) : BaseController
|
|||||||
/// <returns>Paginated list of disciplines.</returns>
|
/// <returns>Paginated list of disciplines.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[BadRequestResponse]
|
[BadRequestResponse]
|
||||||
public async Task<ActionResult<List<DisciplineResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page, [FromQuery][Range(1, int.MaxValue)] int? pageSize)
|
public async Task<ActionResult<List<DisciplineResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
|
||||||
|
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetDisciplineListQuery()
|
var result = await mediator.Send(new GetDisciplineListQuery()
|
||||||
{
|
{
|
||||||
|
@ -23,7 +23,8 @@ public class FacultyController(IMediator mediator) : BaseController
|
|||||||
/// <returns>Paginated list of faculties.</returns>
|
/// <returns>Paginated list of faculties.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[BadRequestResponse]
|
[BadRequestResponse]
|
||||||
public async Task<ActionResult<List<FacultyResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page, [FromQuery][Range(1, int.MaxValue)] int? pageSize)
|
public async Task<ActionResult<List<FacultyResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
|
||||||
|
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetFacultyListQuery()
|
var result = await mediator.Send(new GetFacultyListQuery()
|
||||||
{
|
{
|
||||||
|
@ -38,7 +38,8 @@ public class GroupController(IMediator mediator) : BaseController
|
|||||||
/// <returns>A list of groups.</returns>
|
/// <returns>A list of groups.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[BadRequestResponse]
|
[BadRequestResponse]
|
||||||
public async Task<ActionResult<List<GroupResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page, [FromQuery][Range(1, int.MaxValue)] int? pageSize)
|
public async Task<ActionResult<List<GroupResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
|
||||||
|
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetGroupListQuery()
|
var result = await mediator.Send(new GetGroupListQuery()
|
||||||
{
|
{
|
||||||
|
@ -40,7 +40,7 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
|
|||||||
/// <returns>Excel file</returns>
|
/// <returns>Excel file</returns>
|
||||||
[HttpPost("ImportToExcel")]
|
[HttpPost("ImportToExcel")]
|
||||||
[Produces("application/vnd.ms-excel")]
|
[Produces("application/vnd.ms-excel")]
|
||||||
public async Task<IActionResult> ImportToExcel([FromBody] ScheduleRequest request)
|
public async Task<FileStreamResult> ImportToExcel([FromBody] ScheduleRequest request)
|
||||||
{
|
{
|
||||||
var result = (await mediator.Send(new GetScheduleListQuery
|
var result = (await mediator.Send(new GetScheduleListQuery
|
||||||
{
|
{
|
||||||
@ -49,17 +49,14 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
|
|||||||
GroupIds = request.Groups,
|
GroupIds = request.Groups,
|
||||||
LectureHallIds = request.LectureHalls,
|
LectureHallIds = request.LectureHalls,
|
||||||
ProfessorIds = request.Professors
|
ProfessorIds = request.Professors
|
||||||
})).Schedules;
|
})).Schedules.ToList();
|
||||||
|
|
||||||
if (result.Count == 0)
|
|
||||||
return NoContent();
|
|
||||||
|
|
||||||
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
|
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
|
||||||
using var package = new ExcelPackage();
|
using var package = new ExcelPackage();
|
||||||
var worksheet = package.Workbook.Worksheets.Add("Расписание");
|
var worksheet = package.Workbook.Worksheets.Add("Расписание");
|
||||||
|
|
||||||
int row = 1;
|
var row = 1;
|
||||||
int col = 1;
|
var col = 1;
|
||||||
|
|
||||||
worksheet.Cells[row, col++].Value = "День";
|
worksheet.Cells[row, col++].Value = "День";
|
||||||
worksheet.Cells[row, col++].Value = "Пара";
|
worksheet.Cells[row, col++].Value = "Пара";
|
||||||
|
@ -26,7 +26,8 @@ public class ProfessorController(IMediator mediator) : BaseController
|
|||||||
/// <returns>A list of professors.</returns>
|
/// <returns>A list of professors.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[BadRequestResponse]
|
[BadRequestResponse]
|
||||||
public async Task<ActionResult<List<ProfessorResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page, [FromQuery][Range(1, int.MaxValue)] int? pageSize)
|
public async Task<ActionResult<List<ProfessorResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
|
||||||
|
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetProfessorListQuery()
|
var result = await mediator.Send(new GetProfessorListQuery()
|
||||||
{
|
{
|
||||||
|
@ -66,7 +66,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
|
|||||||
GroupIds = request.Groups,
|
GroupIds = request.Groups,
|
||||||
LectureHallIds = request.LectureHalls,
|
LectureHallIds = request.LectureHalls,
|
||||||
ProfessorIds = request.Professors
|
ProfessorIds = request.Professors
|
||||||
})).Schedules;
|
})).Schedules.ToList();
|
||||||
|
|
||||||
if (result.Count == 0)
|
if (result.Count == 0)
|
||||||
NoContent();
|
NoContent();
|
||||||
|
@ -28,7 +28,7 @@ public class SecurityController(IOptionsSnapshot<GeneralConfig> generalConfig) :
|
|||||||
[HttpGet("GenerateTotpQrCode")]
|
[HttpGet("GenerateTotpQrCode")]
|
||||||
[Produces("image/svg+xml")]
|
[Produces("image/svg+xml")]
|
||||||
[MaintenanceModeIgnore]
|
[MaintenanceModeIgnore]
|
||||||
public IActionResult GenerateTotpQrCode(
|
public ContentResult GenerateTotpQrCode(
|
||||||
[FromQuery] string totpKey,
|
[FromQuery] string totpKey,
|
||||||
[FromQuery] string label,
|
[FromQuery] string label,
|
||||||
[FromQuery] string? backgroundColor = null,
|
[FromQuery] string? backgroundColor = null,
|
||||||
|
@ -19,7 +19,8 @@ using Group = Mirea.Api.DataAccess.Domain.Schedule.Group;
|
|||||||
|
|
||||||
namespace Mirea.Api.Endpoint.Sync;
|
namespace Mirea.Api.Endpoint.Sync;
|
||||||
|
|
||||||
internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSnapshot<GeneralConfig> config, ILogger<ScheduleSynchronizer> logger, IMaintenanceModeService maintenanceMode)
|
internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSnapshot<GeneralConfig> config, ILogger<ScheduleSynchronizer> logger,
|
||||||
|
IMaintenanceModeService maintenanceMode)
|
||||||
{
|
{
|
||||||
private readonly DataRepository<Campus> _campuses = new([.. dbContext.Campuses]);
|
private readonly DataRepository<Campus> _campuses = new([.. dbContext.Campuses]);
|
||||||
private readonly DataRepository<Discipline> _disciplines = new([.. dbContext.Disciplines]);
|
private readonly DataRepository<Discipline> _disciplines = new([.. dbContext.Disciplines]);
|
||||||
@ -120,7 +121,7 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
|
|||||||
{
|
{
|
||||||
hall = [];
|
hall = [];
|
||||||
campuses = [];
|
campuses = [];
|
||||||
for (int i = 0; i < groupInfo.Campuses.Length; i++)
|
for (var i = 0; i < groupInfo.Campuses.Length; i++)
|
||||||
{
|
{
|
||||||
var campus = groupInfo.Campuses[i];
|
var campus = groupInfo.Campuses[i];
|
||||||
campuses.Add(_campuses.GetOrCreate(
|
campuses.Add(_campuses.GetOrCreate(
|
||||||
@ -151,7 +152,7 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
|
|||||||
Name = groupInfo.Discipline
|
Name = groupInfo.Discipline
|
||||||
});
|
});
|
||||||
|
|
||||||
var lesson = _lessons.GetOrCreate(l =>
|
Lesson lesson = _lessons.GetOrCreate(l =>
|
||||||
l.IsEven == groupInfo.IsEven &&
|
l.IsEven == groupInfo.IsEven &&
|
||||||
l.DayOfWeek == groupInfo.Day &&
|
l.DayOfWeek == groupInfo.Day &&
|
||||||
l.PairNumber == groupInfo.Pair &&
|
l.PairNumber == groupInfo.Pair &&
|
||||||
@ -182,9 +183,9 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
|
|||||||
return lesson;
|
return lesson;
|
||||||
});
|
});
|
||||||
|
|
||||||
int maxValue = int.Max(int.Max(professor?.Count ?? -1, hall?.Count ?? -1), 1);
|
var maxValue = int.Max(int.Max(professor?.Count ?? -1, hall?.Count ?? -1), 1);
|
||||||
|
|
||||||
for (int i = 0; i < maxValue; i++)
|
for (var i = 0; i < maxValue; i++)
|
||||||
{
|
{
|
||||||
var prof = professor?.ElementAtOrDefault(i);
|
var prof = professor?.ElementAtOrDefault(i);
|
||||||
var lectureHall = hall?.ElementAtOrDefault(i);
|
var lectureHall = hall?.ElementAtOrDefault(i);
|
||||||
@ -226,7 +227,9 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
|
|||||||
|
|
||||||
if (pairPeriods == null || startTerm == null)
|
if (pairPeriods == null || startTerm == null)
|
||||||
{
|
{
|
||||||
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} or {Arg2} variable is not initialized.", nameof(pairPeriods), nameof(startTerm));
|
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} or {Arg2} variable is not initialized.",
|
||||||
|
nameof(pairPeriods),
|
||||||
|
nameof(startTerm));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
11
Security/Common/Domain/LoginOAuthResult.cs
Normal file
11
Security/Common/Domain/LoginOAuthResult.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Mirea.Api.Security.Common.Domain;
|
||||||
|
|
||||||
|
public class LoginOAuthResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public required string Token { get; set; }
|
||||||
|
public Uri? Callback { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
7
Security/Common/Domain/OAuth2/OAuthPayload.cs
Normal file
7
Security/Common/Domain/OAuth2/OAuthPayload.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Mirea.Api.Security.Common.Domain.OAuth2;
|
||||||
|
|
||||||
|
public class OAuthPayload
|
||||||
|
{
|
||||||
|
public required OAuthProvider Provider { get; set; }
|
||||||
|
public required string Callback { get; set; }
|
||||||
|
}
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace Mirea.Api.Security.Common.Domain.OAuth2;
|
namespace Mirea.Api.Security.Common.Domain.OAuth2;
|
||||||
|
|
||||||
internal struct OAuthProviderUrisData
|
internal readonly struct OAuthProviderUrisData
|
||||||
{
|
{
|
||||||
public string RedirectUrl { get; init; }
|
public string RedirectUrl { get; init; }
|
||||||
public string TokenUrl { get; init; }
|
public string TokenUrl { get; init; }
|
||||||
|
@ -6,11 +6,29 @@ using Mirea.Api.Security.Common.Interfaces;
|
|||||||
using Mirea.Api.Security.Services;
|
using Mirea.Api.Security.Services;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace Mirea.Api.Security;
|
namespace Mirea.Api.Security;
|
||||||
|
|
||||||
public static class DependencyInjection
|
public static class DependencyInjection
|
||||||
{
|
{
|
||||||
|
private static ReadOnlyMemory<byte> NormalizeKey(string key, int requiredLength)
|
||||||
|
{
|
||||||
|
var keyBytes = Encoding.UTF8.GetBytes(key);
|
||||||
|
|
||||||
|
if (keyBytes.Length < requiredLength)
|
||||||
|
{
|
||||||
|
var normalizedKey = new byte[requiredLength];
|
||||||
|
Array.Copy(keyBytes, normalizedKey, keyBytes.Length);
|
||||||
|
return new ReadOnlyMemory<byte>(normalizedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyBytes.Length > requiredLength)
|
||||||
|
Array.Resize(ref keyBytes, requiredLength);
|
||||||
|
|
||||||
|
return new ReadOnlyMemory<byte>(keyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddSecurityServices(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddSecurityServices(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
var saltSize = int.Parse(configuration["SECURITY_SALT_SIZE"]!);
|
var saltSize = int.Parse(configuration["SECURITY_SALT_SIZE"]!);
|
||||||
@ -61,7 +79,13 @@ public static class DependencyInjection
|
|||||||
providers.Add(provider, (clientId, secret));
|
providers.Add(provider, (clientId, secret));
|
||||||
}
|
}
|
||||||
|
|
||||||
services.AddSingleton(provider => new OAuthService(provider.GetRequiredService<ILogger<OAuthService>>(), providers, configuration["SECURITY_ENCRYPTION_TOKEN"]!));
|
services.AddSingleton(provider => new OAuthService(
|
||||||
|
provider.GetRequiredService<ILogger<OAuthService>>(),
|
||||||
|
providers,
|
||||||
|
provider.GetRequiredService<ICacheService>())
|
||||||
|
{
|
||||||
|
SecretKey = NormalizeKey(configuration["SECURITY_ENCRYPTION_TOKEN"]!, 32)
|
||||||
|
});
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,8 @@ using System.Threading.Tasks;
|
|||||||
|
|
||||||
namespace Mirea.Api.Security.Services;
|
namespace Mirea.Api.Security.Services;
|
||||||
|
|
||||||
public class AuthService(ICacheService cache, IAccessToken accessTokenService, IRevokedToken revokedToken, ILogger<AuthService> logger, PasswordHashService passwordService)
|
public class AuthService(ICacheService cache, IAccessToken accessTokenService, IRevokedToken revokedToken, ILogger<AuthService> logger,
|
||||||
|
PasswordHashService passwordService)
|
||||||
{
|
{
|
||||||
public TimeSpan Lifetime { private get; init; }
|
public TimeSpan Lifetime { private get; init; }
|
||||||
public TimeSpan LifetimeFirstAuth { private get; init; }
|
public TimeSpan LifetimeFirstAuth { private get; init; }
|
||||||
@ -24,15 +25,16 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
|
|
||||||
private static string GetAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token";
|
private static string GetAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token";
|
||||||
private static string GetFirstAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token_first";
|
private static string GetFirstAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token_first";
|
||||||
|
private static string GetAttemptFailedCountKey(string fingerprint) => $"{fingerprint}_login_failed";
|
||||||
|
|
||||||
private Task SetAuthTokenDataToCache(AuthToken data, CancellationToken cancellation) =>
|
private Task StoreAuthTokenInCache(AuthToken data, CancellationToken cancellation) =>
|
||||||
cache.SetAsync(
|
cache.SetAsync(
|
||||||
GetAuthCacheKey(data.Fingerprint),
|
GetAuthCacheKey(data.Fingerprint),
|
||||||
JsonSerializer.SerializeToUtf8Bytes(data),
|
JsonSerializer.SerializeToUtf8Bytes(data),
|
||||||
slidingExpiration: Lifetime,
|
slidingExpiration: Lifetime,
|
||||||
cancellationToken: cancellation);
|
cancellationToken: cancellation);
|
||||||
|
|
||||||
private Task CreateFirstAuthTokenToCache(User data, RequestContextInfo requestContext, CancellationToken cancellation) =>
|
private Task StoreFirstAuthTokenInCache(User data, RequestContextInfo requestContext, CancellationToken cancellation) =>
|
||||||
cache.SetAsync(
|
cache.SetAsync(
|
||||||
GetFirstAuthCacheKey(requestContext.Fingerprint),
|
GetFirstAuthCacheKey(requestContext.Fingerprint),
|
||||||
JsonSerializer.SerializeToUtf8Bytes(new FirstAuthToken(requestContext)
|
JsonSerializer.SerializeToUtf8Bytes(new FirstAuthToken(requestContext)
|
||||||
@ -47,39 +49,53 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
private Task RevokeAccessToken(string token) =>
|
private Task RevokeAccessToken(string token) =>
|
||||||
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
|
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
|
||||||
|
|
||||||
private async Task VerifyUserOrThrowError(RequestContextInfo requestContext, User user, string password, string username,
|
private async Task RecordFailedLoginAttempt(string fingerprint, string userId, CancellationToken cancellation)
|
||||||
CancellationToken cancellation = default)
|
|
||||||
{
|
{
|
||||||
if ((user.Email.Equals(username, StringComparison.OrdinalIgnoreCase) || user.Username.Equals(username, StringComparison.OrdinalIgnoreCase)) &&
|
var failedLoginAttemptsCount = await cache.GetAsync<int?>(GetAttemptFailedCountKey(fingerprint), cancellation) ?? 1;
|
||||||
passwordService.VerifyPassword(password, user.Salt, user.PasswordHash))
|
var failedLoginCacheExpiration = TimeSpan.FromHours(1);
|
||||||
return;
|
|
||||||
|
|
||||||
var failedLoginCacheName = $"{requestContext.Fingerprint}_login_failed";
|
if (failedLoginAttemptsCount > 5)
|
||||||
var countFailedLogin = await cache.GetAsync<int?>(failedLoginCacheName, cancellation) ?? 1;
|
|
||||||
var cacheSaveTime = TimeSpan.FromHours(1);
|
|
||||||
|
|
||||||
await cache.SetAsync(failedLoginCacheName, countFailedLogin + 1, slidingExpiration: cacheSaveTime, cancellationToken: cancellation);
|
|
||||||
|
|
||||||
if (countFailedLogin > 5)
|
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Multiple unsuccessful login attempts for user ID {UserId}. Attempt count: {AttemptNumber}.",
|
"Multiple unsuccessful login attempts for user ID {UserId}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
|
||||||
user.Id,
|
userId,
|
||||||
countFailedLogin);
|
fingerprint,
|
||||||
|
failedLoginAttemptsCount);
|
||||||
|
|
||||||
throw new SecurityException("Too many unsuccessful login attempts. Please try again later.");
|
throw new SecurityException("Too many unsuccessful login attempts. Please try again later.");
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Login attempt failed for user ID {UserId}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
|
"Login attempt failed for user ID {UserId}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
|
||||||
user.Id,
|
userId,
|
||||||
requestContext.Fingerprint,
|
fingerprint,
|
||||||
countFailedLogin);
|
failedLoginAttemptsCount);
|
||||||
|
|
||||||
|
await cache.SetAsync(GetAttemptFailedCountKey(fingerprint), failedLoginAttemptsCount + 1,
|
||||||
|
slidingExpiration: failedLoginCacheExpiration, cancellationToken: cancellation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task ResetFailedLoginAttempts(string fingerprint, CancellationToken cancellation) =>
|
||||||
|
cache.RemoveAsync(GetAttemptFailedCountKey(fingerprint), cancellation);
|
||||||
|
|
||||||
|
private async Task VerifyUserOrThrowError(RequestContextInfo requestContext, User user, string password, string username,
|
||||||
|
CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
if ((user.Email.Equals(username, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
user.Username.Equals(username, StringComparison.OrdinalIgnoreCase)) &&
|
||||||
|
passwordService.VerifyPassword(password, user.Salt, user.PasswordHash))
|
||||||
|
{
|
||||||
|
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RecordFailedLoginAttempt(requestContext.Fingerprint, user.Id, cancellation);
|
||||||
|
|
||||||
throw new SecurityException("Authentication failed. Please check your credentials.");
|
throw new SecurityException("Authentication failed. Please check your credentials.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task GenerateAuthTokensAsync(CookieOptionsParameters cookieOptions, HttpContext context, RequestContextInfo requestContext, string userId, CancellationToken cancellation = default)
|
private async Task GenerateAuthTokensAsync(CookieOptionsParameters cookieOptions, HttpContext context,
|
||||||
|
RequestContextInfo requestContext, string userId, CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
var refreshToken = GenerateRefreshToken();
|
var refreshToken = GenerateRefreshToken();
|
||||||
var (token, expireIn) = GenerateAccessToken(userId);
|
var (token, expireIn) = GenerateAccessToken(userId);
|
||||||
@ -92,7 +108,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
AccessToken = token
|
AccessToken = token
|
||||||
};
|
};
|
||||||
|
|
||||||
await SetAuthTokenDataToCache(authToken, cancellation);
|
await StoreAuthTokenInCache(authToken, cancellation);
|
||||||
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
|
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
|
||||||
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
|
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
|
||||||
|
|
||||||
@ -102,7 +118,8 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
authToken.Fingerprint);
|
authToken.Fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TwoFactorAuthenticator> LoginOAuthAsync(CookieOptionsParameters cookieOptions, HttpContext context, User user, CancellationToken cancellation = default)
|
public async Task<TwoFactorAuthenticator> LoginOAuthAsync(CookieOptionsParameters cookieOptions, HttpContext context, User user,
|
||||||
|
CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
var requestContext = new RequestContextInfo(context, cookieOptions);
|
var requestContext = new RequestContextInfo(context, cookieOptions);
|
||||||
|
|
||||||
@ -112,12 +129,13 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
return TwoFactorAuthenticator.None;
|
return TwoFactorAuthenticator.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CreateFirstAuthTokenToCache(user, requestContext, cancellation);
|
await StoreFirstAuthTokenInCache(user, requestContext, cancellation);
|
||||||
|
|
||||||
return user.TwoFactorAuthenticator;
|
return user.TwoFactorAuthenticator;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code, CancellationToken cancellation = default)
|
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code,
|
||||||
|
CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
var requestContext = new RequestContextInfo(context, cookieOptions);
|
var requestContext = new RequestContextInfo(context, cookieOptions);
|
||||||
|
|
||||||
@ -131,12 +149,23 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
case TwoFactorAuthenticator.Totp:
|
case TwoFactorAuthenticator.Totp:
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(firstTokenAuth.Secret))
|
if (string.IsNullOrEmpty(firstTokenAuth.Secret))
|
||||||
|
{
|
||||||
|
logger.LogWarning("The user {Fingerprint} for {UserId} tried to pass the 2FA even though the secret is empty",
|
||||||
|
requestContext.Fingerprint,
|
||||||
|
firstTokenAuth.UserId);
|
||||||
|
|
||||||
throw new InvalidOperationException("Required authentication data is missing.");
|
throw new InvalidOperationException("Required authentication data is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
var totp = new TotpService(firstTokenAuth.Secret);
|
var totp = new TotpService(firstTokenAuth.Secret);
|
||||||
|
|
||||||
if (!totp.VerifyToken(code))
|
if (!totp.VerifyToken(code))
|
||||||
|
{
|
||||||
|
await RecordFailedLoginAttempt(requestContext.Fingerprint, firstTokenAuth.UserId, cancellation);
|
||||||
throw new SecurityException("Invalid verification code. Please try again.");
|
throw new SecurityException("Invalid verification code. Please try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@ -147,10 +176,11 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TwoFactorAuthenticator> LoginAsync(CookieOptionsParameters cookieOptions, User user, HttpContext context, string password, string username, CancellationToken cancellation = default)
|
public async Task<TwoFactorAuthenticator> LoginAsync(CookieOptionsParameters cookieOptions, User user, HttpContext context, string password,
|
||||||
|
string username, CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
var requestContext = new RequestContextInfo(context, cookieOptions);
|
var requestContext = new RequestContextInfo(context, cookieOptions);
|
||||||
|
username = username.Trim();
|
||||||
await VerifyUserOrThrowError(requestContext, user, password, username, cancellation);
|
await VerifyUserOrThrowError(requestContext, user, password, username, cancellation);
|
||||||
|
|
||||||
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
|
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
|
||||||
@ -159,16 +189,17 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
return TwoFactorAuthenticator.None;
|
return TwoFactorAuthenticator.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CreateFirstAuthTokenToCache(user, requestContext, cancellation);
|
await StoreFirstAuthTokenInCache(user, requestContext, cancellation);
|
||||||
|
|
||||||
return user.TwoFactorAuthenticator;
|
return user.TwoFactorAuthenticator;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RefreshTokenAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
|
public async Task RefreshTokenAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
|
const string defaultMessageError = "The session time has expired";
|
||||||
var requestContext = new RequestContextInfo(context, cookieOptions);
|
var requestContext = new RequestContextInfo(context, cookieOptions);
|
||||||
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation)
|
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation) ??
|
||||||
?? throw new SecurityException("The session time has expired");
|
throw new SecurityException(defaultMessageError);
|
||||||
|
|
||||||
if (authToken.RefreshToken != requestContext.RefreshToken ||
|
if (authToken.RefreshToken != requestContext.RefreshToken ||
|
||||||
authToken.UserAgent != requestContext.UserAgent &&
|
authToken.UserAgent != requestContext.UserAgent &&
|
||||||
@ -179,14 +210,52 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
cookieOptions.DropCookie(context, CookieNames.AccessToken);
|
cookieOptions.DropCookie(context, CookieNames.AccessToken);
|
||||||
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
|
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
|
||||||
|
|
||||||
logger.LogWarning("Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. Reason: {Reason}.",
|
const string error = "Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. ";
|
||||||
authToken.UserId,
|
if (authToken.RefreshToken != requestContext.RefreshToken)
|
||||||
authToken.Fingerprint,
|
logger.LogWarning(
|
||||||
authToken.RefreshToken != requestContext.RefreshToken ?
|
error +
|
||||||
$"Cached refresh token '{authToken.RefreshToken}' does not match the provided refresh token '{requestContext.RefreshToken}'" :
|
"Cached refresh token {ExpectedRefreshToken} does not match the provided refresh token {RefreshToken}",
|
||||||
$"User-Agent '{authToken.UserAgent}' and IP '{authToken.Ip}' in cache do not match the provided User-Agent '{requestContext.UserAgent}' and IP '{requestContext.Ip}'");
|
authToken.UserId,
|
||||||
|
authToken.Fingerprint,
|
||||||
|
authToken.RefreshToken,
|
||||||
|
requestContext.RefreshToken);
|
||||||
|
else
|
||||||
|
logger.LogWarning(
|
||||||
|
error +
|
||||||
|
"User-Agent {ExpectedUserAgent} and IP {ExpectedUserIp} in cache do not match the provided " +
|
||||||
|
"User-Agent {ProvidedUserAgent} and IP {ProvidedIp}",
|
||||||
|
authToken.UserId,
|
||||||
|
authToken.Fingerprint,
|
||||||
|
authToken.UserAgent,
|
||||||
|
authToken.Ip,
|
||||||
|
requestContext.UserAgent,
|
||||||
|
requestContext.Ip);
|
||||||
|
|
||||||
throw new SecurityException("The session time has expired");
|
throw new SecurityException(defaultMessageError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authToken.UserAgent != requestContext.UserAgent)
|
||||||
|
{
|
||||||
|
logger.LogInformation("The resulting User-Agent {ProvidedUserAgent} does not match the cached " +
|
||||||
|
"{ExpectedUserAgent} of the user {UserId} with the fingerprint {Fingerprint}.",
|
||||||
|
requestContext.UserAgent,
|
||||||
|
authToken.UserAgent,
|
||||||
|
authToken.UserId,
|
||||||
|
requestContext.Fingerprint);
|
||||||
|
|
||||||
|
authToken.UserAgent = requestContext.UserAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authToken.Ip != requestContext.Ip)
|
||||||
|
{
|
||||||
|
logger.LogInformation("The resulting Ip {ProvidedIp} does not match the cached " +
|
||||||
|
"{ExpectedIp} of the user {UserId} with the fingerprint {Fingerprint}.",
|
||||||
|
requestContext.Ip,
|
||||||
|
authToken.Ip,
|
||||||
|
authToken.UserId,
|
||||||
|
requestContext.Fingerprint);
|
||||||
|
|
||||||
|
authToken.Ip = requestContext.Ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (token, expireIn) = GenerateAccessToken(authToken.UserId);
|
var (token, expireIn) = GenerateAccessToken(authToken.UserId);
|
||||||
@ -197,7 +266,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
authToken.AccessToken = token;
|
authToken.AccessToken = token;
|
||||||
authToken.RefreshToken = newRefreshToken;
|
authToken.RefreshToken = newRefreshToken;
|
||||||
|
|
||||||
await SetAuthTokenDataToCache(authToken, cancellation);
|
await StoreAuthTokenInCache(authToken, cancellation);
|
||||||
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
|
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
|
||||||
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
|
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ public static class GeneratorKey
|
|||||||
var random = new Random();
|
var random = new Random();
|
||||||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
|
||||||
string charsForGenerate = excludes?
|
var charsForGenerate = excludes?
|
||||||
.Aggregate(chars, (current, ex) => current.Replace(ex.ToString(), string.Empty)) ?? chars;
|
.Aggregate(chars, (current, ex) => current.Replace(ex.ToString(), string.Empty)) ?? chars;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(includes))
|
if (!string.IsNullOrEmpty(includes))
|
||||||
|
@ -6,10 +6,10 @@ using Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
|
|||||||
using Mirea.Api.Security.Common.Interfaces;
|
using Mirea.Api.Security.Common.Interfaces;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Security;
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@ -18,8 +18,11 @@ using System.Threading.Tasks;
|
|||||||
|
|
||||||
namespace Mirea.Api.Security.Services;
|
namespace Mirea.Api.Security.Services;
|
||||||
|
|
||||||
public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider, (string ClientId, string Secret)> providers, string secretKey)
|
public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider, (string ClientId, string Secret)> providers,
|
||||||
|
ICacheService cache)
|
||||||
{
|
{
|
||||||
|
public required ReadOnlyMemory<byte> SecretKey { private get; init; }
|
||||||
|
|
||||||
private static readonly Dictionary<OAuthProvider, OAuthProviderUrisData> ProviderData = new()
|
private static readonly Dictionary<OAuthProvider, OAuthProviderUrisData> ProviderData = new()
|
||||||
{
|
{
|
||||||
[OAuthProvider.Google] = new OAuthProviderUrisData
|
[OAuthProvider.Google] = new OAuthProviderUrisData
|
||||||
@ -51,7 +54,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private static async Task<OAuthTokenResponse?> ExchangeCodeForTokensAsync(string requestUri, string redirectUrl, string code, string clientId, string secret, CancellationToken cancellation)
|
private static async Task<OAuthTokenResponse?> ExchangeCodeForTokensAsync(string requestUri, string redirectUrl, string code,
|
||||||
|
string clientId, string secret, CancellationToken cancellation)
|
||||||
{
|
{
|
||||||
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
|
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
|
||||||
{
|
{
|
||||||
@ -77,7 +81,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
|||||||
return JsonSerializer.Deserialize<OAuthTokenResponse>(data);
|
return JsonSerializer.Deserialize<OAuthTokenResponse>(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<OAuthUser?> GetUserProfileAsync(string requestUri, string authHeader, string accessToken, OAuthProvider provider, CancellationToken cancellation)
|
private static async Task<OAuthUser?> GetUserProfileAsync(string requestUri, string authHeader, string accessToken, OAuthProvider provider,
|
||||||
|
CancellationToken cancellation)
|
||||||
{
|
{
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||||
|
|
||||||
@ -99,30 +104,90 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
|||||||
return userInfo?.MapToInternalUser();
|
return userInfo?.MapToInternalUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetHmacString(RequestContextInfo contextInfo, string secretKey)
|
private string GetHmacString(RequestContextInfo contextInfo)
|
||||||
{
|
{
|
||||||
var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey));
|
var hmac = new HMACSHA256(SecretKey.ToArray());
|
||||||
return Convert.ToBase64String(hmac.ComputeHash(
|
return Convert.ToBase64String(hmac.ComputeHash(
|
||||||
Encoding.UTF8.GetBytes($"{contextInfo.Fingerprint}_{contextInfo.Ip}_{contextInfo.UserAgent}")));
|
Encoding.UTF8.GetBytes($"{contextInfo.Fingerprint}_{contextInfo.Ip}_{contextInfo.UserAgent}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Uri GetProviderRedirect(HttpContext context, CookieOptionsParameters cookieOptions, string redirectUri, OAuthProvider provider)
|
private string EncryptPayload(OAuthPayload payload)
|
||||||
|
{
|
||||||
|
var data = JsonSerializer.Serialize(payload);
|
||||||
|
|
||||||
|
var aes = Aes.Create();
|
||||||
|
aes.Key = SecretKey.ToArray();
|
||||||
|
aes.GenerateIV();
|
||||||
|
|
||||||
|
using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
|
||||||
|
ms.Write(aes.IV, 0, aes.IV.Length);
|
||||||
|
|
||||||
|
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
|
||||||
|
using (var writer = new StreamWriter(cs))
|
||||||
|
{
|
||||||
|
writer.Write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.ToBase64String(ms.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private OAuthPayload DecryptPayload(string encryptedData)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cipherBytes = Convert.FromBase64String(encryptedData);
|
||||||
|
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = SecretKey.ToArray();
|
||||||
|
|
||||||
|
var iv = new byte[16];
|
||||||
|
Array.Copy(cipherBytes, 0, iv, 0, iv.Length);
|
||||||
|
aes.IV = iv;
|
||||||
|
|
||||||
|
using var ms = new MemoryStream(cipherBytes, 16, cipherBytes.Length - 16);
|
||||||
|
using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
|
||||||
|
|
||||||
|
using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
|
||||||
|
using var reader = new StreamReader(cs);
|
||||||
|
|
||||||
|
var data = reader.ReadToEnd();
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<OAuthPayload>(data) ??
|
||||||
|
throw new NullReferenceException($"Couldn't convert data to {nameof(OAuthPayload)}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Couldn't decrypt the data OAuth request.");
|
||||||
|
throw new InvalidOperationException("Couldn't decrypt the data.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Uri GetProviderRedirect(HttpContext context, CookieOptionsParameters cookieOptions, string redirectUri,
|
||||||
|
OAuthProvider provider, Uri callback)
|
||||||
{
|
{
|
||||||
var (clientId, _) = providers[provider];
|
var (clientId, _) = providers[provider];
|
||||||
|
|
||||||
var requestInfo = new RequestContextInfo(context, cookieOptions);
|
var requestInfo = new RequestContextInfo(context, cookieOptions);
|
||||||
var state = GetHmacString(requestInfo, secretKey);
|
var payload = EncryptPayload(new OAuthPayload()
|
||||||
|
{
|
||||||
|
Provider = provider,
|
||||||
|
Callback = callback.AbsoluteUri
|
||||||
|
});
|
||||||
|
|
||||||
|
var checksum = GetHmacString(requestInfo);
|
||||||
|
|
||||||
var redirectUrl = $"?client_id={clientId}" +
|
var redirectUrl = $"?client_id={clientId}" +
|
||||||
"&response_type=code" +
|
"&response_type=code" +
|
||||||
$"&redirect_uri={redirectUri}" +
|
$"&redirect_uri={redirectUri}" +
|
||||||
$"&scope={ProviderData[provider].Scope}" +
|
$"&scope={ProviderData[provider].Scope}" +
|
||||||
$"&state={Uri.EscapeDataString(state + "_" + Enum.GetName(provider))}";
|
$"&state={Uri.EscapeDataString(payload + "_" + checksum)}";
|
||||||
|
|
||||||
logger.LogInformation("Redirecting user Fingerprint: {Fingerprint} to OAuth provider {Provider} with state: {State}",
|
logger.LogInformation("Redirecting user Fingerprint: {Fingerprint} to OAuth provider {Provider} with state: {State}",
|
||||||
requestInfo.Fingerprint,
|
requestInfo.Fingerprint,
|
||||||
provider,
|
provider,
|
||||||
state);
|
checksum);
|
||||||
|
|
||||||
return new Uri(ProviderData[provider].RedirectUrl + redirectUrl);
|
return new Uri(ProviderData[provider].RedirectUrl + redirectUrl);
|
||||||
}
|
}
|
||||||
@ -130,59 +195,98 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
|||||||
public (OAuthProvider Provider, Uri Redirect)[] GetAvailableProviders(string redirectUri) =>
|
public (OAuthProvider Provider, Uri Redirect)[] GetAvailableProviders(string redirectUri) =>
|
||||||
[.. providers.Select(x => (x.Key, new Uri(redirectUri.TrimEnd('/') + "/?provider=" + (int)x.Key)))];
|
[.. providers.Select(x => (x.Key, new Uri(redirectUri.TrimEnd('/') + "/?provider=" + (int)x.Key)))];
|
||||||
|
|
||||||
public async Task<(OAuthProvider provider, OAuthUser User)> LoginOAuth(HttpContext context, CookieOptionsParameters cookieOptions, string redirectUrl, string code, string state, CancellationToken cancellation = default)
|
public async Task<LoginOAuthResult> LoginOAuth(HttpContext context, CookieOptionsParameters cookieOptions,
|
||||||
|
string redirectUrl, string code, string state, CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
var partsState = state.Split('_');
|
var result = new LoginOAuthResult()
|
||||||
|
|
||||||
if (!Enum.TryParse<OAuthProvider>(partsState.Last(), true, out var provider) ||
|
|
||||||
!providers.TryGetValue(provider, out var providerInfo) ||
|
|
||||||
!ProviderData.TryGetValue(provider, out var currentProviderStruct))
|
|
||||||
{
|
{
|
||||||
logger.LogWarning("Failed to parse OAuth provider from state: {State}", state);
|
Token = GeneratorKey.GenerateBase64(32)
|
||||||
throw new InvalidOperationException("Invalid authorization request.");
|
};
|
||||||
|
var parts = state.Split('_');
|
||||||
|
|
||||||
|
if (parts.Length != 2)
|
||||||
|
{
|
||||||
|
result.ErrorMessage = "The request data is invalid or malformed.";
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
var secretStateData = string.Join("_", partsState.SkipLast(1));
|
var payload = DecryptPayload(parts[0]);
|
||||||
var requestInfo = new RequestContextInfo(context, cookieOptions);
|
var checksum = parts[1];
|
||||||
var secretData = GetHmacString(requestInfo, secretKey);
|
|
||||||
|
|
||||||
if (secretData != secretStateData)
|
result.Callback = new Uri(payload.Callback);
|
||||||
|
|
||||||
|
if (!providers.TryGetValue(payload.Provider, out var providerInfo) ||
|
||||||
|
!ProviderData.TryGetValue(payload.Provider, out var currentProviderStruct))
|
||||||
|
{
|
||||||
|
logger.LogWarning("The OAuth provider specified in the payload " +
|
||||||
|
"is not registered as a possible data recipient from state: {State}",
|
||||||
|
state);
|
||||||
|
|
||||||
|
result.ErrorMessage = "Invalid authorization request. Please try again later.";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestInfo = new RequestContextInfo(context, cookieOptions);
|
||||||
|
var checksumRequest = GetHmacString(requestInfo);
|
||||||
|
|
||||||
|
result.ErrorMessage = "Authorization failed. Please try again later.";
|
||||||
|
|
||||||
|
if (checksumRequest != checksum)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Fingerprint mismatch. Possible CSRF attack detected. Fingerprint: {Fingerprint}, State: {State}, ExpectedState: {ExpectedState}",
|
"Fingerprint mismatch. Possible CSRF attack detected. Fingerprint: {Fingerprint}, State: {State}, ExpectedState: {ExpectedState}",
|
||||||
requestInfo.Fingerprint,
|
requestInfo.Fingerprint,
|
||||||
secretData,
|
checksumRequest,
|
||||||
secretStateData
|
checksum
|
||||||
);
|
);
|
||||||
throw new SecurityException("Suspicious activity detected. Please try again.");
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
OAuthTokenResponse? accessToken = null;
|
OAuthTokenResponse? accessToken;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
accessToken = await ExchangeCodeForTokensAsync(currentProviderStruct.TokenUrl, redirectUrl, code, providerInfo.ClientId, providerInfo.Secret, cancellation);
|
accessToken = await ExchangeCodeForTokensAsync(currentProviderStruct.TokenUrl, redirectUrl, code, providerInfo.ClientId,
|
||||||
|
providerInfo.Secret, cancellation);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to exchange code for access token with provider {Provider}. State: {State}", provider, state);
|
logger.LogWarning(ex, "Failed to exchange code for access token with provider {Provider}. State: {State}",
|
||||||
|
payload.Provider,
|
||||||
|
checksum);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accessToken == null)
|
if (accessToken == null)
|
||||||
throw new SecurityException("Unable to complete authorization with the provider. Please try again later.");
|
return result;
|
||||||
|
|
||||||
OAuthUser? result = null;
|
OAuthUser? user;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
result = await GetUserProfileAsync(currentProviderStruct.UserInfoUrl, currentProviderStruct.AuthHeader, accessToken.AccessToken, provider, cancellation);
|
user = await GetUserProfileAsync(currentProviderStruct.UserInfoUrl, currentProviderStruct.AuthHeader, accessToken.AccessToken,
|
||||||
|
payload.Provider, cancellation);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to retrieve user information from provider {Provider}", provider);
|
logger.LogWarning(ex, "Failed to retrieve user information from provider {Provider}",
|
||||||
|
payload.Provider);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result == null)
|
if (user == null)
|
||||||
throw new SecurityException("Unable to retrieve user information. Please check the details and try again.");
|
return result;
|
||||||
|
|
||||||
return (provider, result);
|
result.ErrorMessage = null;
|
||||||
|
result.Success = true;
|
||||||
|
|
||||||
|
await cache.SetAsync(
|
||||||
|
result.Token,
|
||||||
|
JsonSerializer.SerializeToUtf8Bytes(user),
|
||||||
|
absoluteExpirationRelativeToNow: TimeSpan.FromMinutes(15),
|
||||||
|
cancellationToken: cancellation);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -34,8 +34,8 @@ public class PasswordHashService
|
|||||||
if (a.Length != b.Length)
|
if (a.Length != b.Length)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
int result = 0;
|
var result = 0;
|
||||||
for (int i = 0; i < a.Length; i++)
|
for (var i = 0; i < a.Length; i++)
|
||||||
result |= a[i] ^ b[i];
|
result |= a[i] ^ b[i];
|
||||||
return result == 0;
|
return result == 0;
|
||||||
}
|
}
|
||||||
|
@ -10,5 +10,5 @@ public class CampusBasicInfoVm
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of campus basic information.
|
/// The list of campus basic information.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IList<CampusBasicInfoDto> Campuses { get; set; } = new List<CampusBasicInfoDto>();
|
public IEnumerable<CampusBasicInfoDto> Campuses { get; set; } = [];
|
||||||
}
|
}
|
@ -11,7 +11,8 @@ public class GetCampusDetailsQueryHandler(ICampusDbContext dbContext) : IRequest
|
|||||||
{
|
{
|
||||||
public async Task<CampusDetailsVm> Handle(GetCampusDetailsQuery request, CancellationToken cancellationToken)
|
public async Task<CampusDetailsVm> Handle(GetCampusDetailsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var campus = await dbContext.Campuses.FirstOrDefaultAsync(c => c.Id == request.Id, cancellationToken) ?? throw new NotFoundException(typeof(Domain.Schedule.Campus), request.Id);
|
var campus = await dbContext.Campuses.FirstOrDefaultAsync(c => c.Id == request.Id, cancellationToken) ??
|
||||||
|
throw new NotFoundException(typeof(Domain.Schedule.Campus), request.Id);
|
||||||
|
|
||||||
return new CampusDetailsVm()
|
return new CampusDetailsVm()
|
||||||
{
|
{
|
||||||
|
@ -11,7 +11,8 @@ public class GetDisciplineInfoQueryHandler(IDisciplineDbContext dbContext) : IRe
|
|||||||
{
|
{
|
||||||
public async Task<DisciplineInfoVm> Handle(GetDisciplineInfoQuery request, CancellationToken cancellationToken)
|
public async Task<DisciplineInfoVm> Handle(GetDisciplineInfoQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var discipline = await dbContext.Disciplines.FirstOrDefaultAsync(d => d.Id == request.Id, cancellationToken) ?? throw new NotFoundException(typeof(Domain.Schedule.Discipline), request.Id);
|
var discipline = await dbContext.Disciplines.FirstOrDefaultAsync(d => d.Id == request.Id, cancellationToken) ??
|
||||||
|
throw new NotFoundException(typeof(Domain.Schedule.Discipline), request.Id);
|
||||||
|
|
||||||
return new DisciplineInfoVm()
|
return new DisciplineInfoVm()
|
||||||
{
|
{
|
||||||
|
@ -10,5 +10,5 @@ public class DisciplineListVm
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of disciplines.
|
/// The list of disciplines.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IList<DisciplineLookupDto> Disciplines { get; set; } = new List<DisciplineLookupDto>();
|
public IEnumerable<DisciplineLookupDto> Disciplines { get; set; } = [];
|
||||||
}
|
}
|
@ -10,5 +10,5 @@ public class FacultyListVm
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of faculties.
|
/// The list of faculties.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IList<FacultyLookupDto> Faculties { get; set; } = new List<FacultyLookupDto>();
|
public IEnumerable<FacultyLookupDto> Faculties { get; set; } = [];
|
||||||
}
|
}
|
@ -14,9 +14,4 @@ public class FacultyLookupDto
|
|||||||
/// The name of the faculty.
|
/// The name of the faculty.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ID indicating the faculty's affiliation to the campus.
|
|
||||||
/// </summary>
|
|
||||||
public int? CampusId { get; set; }
|
|
||||||
}
|
}
|
@ -10,5 +10,5 @@ public class GroupListVm
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of groups.
|
/// The list of groups.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IList<GroupLookupDto> Groups { get; set; } = new List<GroupLookupDto>();
|
public IEnumerable<GroupLookupDto> Groups { get; set; } = [];
|
||||||
}
|
}
|
@ -10,5 +10,5 @@ public class LectureHallListVm
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of lecture hall.
|
/// The list of lecture hall.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IList<LectureHallLookupDto> LectureHalls { get; set; } = new List<LectureHallLookupDto>();
|
public IEnumerable<LectureHallLookupDto> LectureHalls { get; set; } = [];
|
||||||
}
|
}
|
@ -11,7 +11,8 @@ public class GetProfessorInfoQueryHandler(IProfessorDbContext dbContext) : IRequ
|
|||||||
{
|
{
|
||||||
public async Task<ProfessorInfoVm> Handle(GetProfessorInfoQuery request, CancellationToken cancellationToken)
|
public async Task<ProfessorInfoVm> Handle(GetProfessorInfoQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var professor = await dbContext.Professors.FirstOrDefaultAsync(p => p.Id == request.Id, cancellationToken) ?? throw new NotFoundException(typeof(Domain.Schedule.Professor), request.Id);
|
var professor = await dbContext.Professors.FirstOrDefaultAsync(p => p.Id == request.Id, cancellationToken) ??
|
||||||
|
throw new NotFoundException(typeof(Domain.Schedule.Professor), request.Id);
|
||||||
|
|
||||||
return new ProfessorInfoVm()
|
return new ProfessorInfoVm()
|
||||||
{
|
{
|
||||||
|
@ -27,7 +27,7 @@ public class GetProfessorInfoSearchQueryHandler(IProfessorDbContext dbContext) :
|
|||||||
Id = x.Id,
|
Id = x.Id,
|
||||||
Name = x.Name,
|
Name = x.Name,
|
||||||
AltName = x.AltName
|
AltName = x.AltName
|
||||||
}).ToList()
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -11,5 +11,5 @@ public class ProfessorInfoListVm
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// List of <see cref="ProfessorInfoVm"/>
|
/// List of <see cref="ProfessorInfoVm"/>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required IList<ProfessorInfoVm> Details { get; set; }
|
public IEnumerable<ProfessorInfoVm> Details { get; set; } = [];
|
||||||
}
|
}
|
@ -10,5 +10,5 @@ public class ProfessorListVm
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of professors.
|
/// The list of professors.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IList<ProfessorLookupDto> Professors { get; set; } = new List<ProfessorLookupDto>();
|
public IEnumerable<ProfessorLookupDto> Professors { get; set; } = [];
|
||||||
}
|
}
|
@ -83,7 +83,7 @@ public class GetScheduleListQueryHandler(ILessonDbContext dbContext) : IRequestH
|
|||||||
.Select(la => la.ProfessorId),
|
.Select(la => la.ProfessorId),
|
||||||
|
|
||||||
LinkToMeet = l.LessonAssociations!.Select(la => la.LinkToMeet)
|
LinkToMeet = l.LessonAssociations!.Select(la => la.LinkToMeet)
|
||||||
}).ToList();
|
});
|
||||||
|
|
||||||
return new ScheduleListVm
|
return new ScheduleListVm
|
||||||
{
|
{
|
||||||
|
@ -10,5 +10,5 @@ public class ScheduleListVm
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of schedules.
|
/// Gets or sets the list of schedules.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IList<ScheduleLookupDto> Schedules { get; set; } = new List<ScheduleLookupDto>();
|
public IEnumerable<ScheduleLookupDto> Schedules { get; set; } = [];
|
||||||
}
|
}
|
@ -1,4 +1,4 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Mirea.Api.DataAccess.Application.Common.Behaviors;
|
using Mirea.Api.DataAccess.Application.Common.Behaviors;
|
||||||
@ -11,7 +11,7 @@ public static class DependencyInjection
|
|||||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
|
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
|
||||||
services.AddValidatorsFromAssemblies(new[] { Assembly.GetExecutingAssembly() });
|
services.AddValidatorsFromAssemblies([Assembly.GetExecutingAssembly()]);
|
||||||
services.AddTransient(typeof(IPipelineBehavior<,>),
|
services.AddTransient(typeof(IPipelineBehavior<,>),
|
||||||
typeof(ValidationBehavior<,>));
|
typeof(ValidationBehavior<,>));
|
||||||
return services;
|
return services;
|
||||||
|
@ -9,7 +9,7 @@ public static class ModelBuilderExtensions
|
|||||||
{
|
{
|
||||||
var applyGenericMethod = typeof(ModelBuilder)
|
var applyGenericMethod = typeof(ModelBuilder)
|
||||||
.GetMethods()
|
.GetMethods()
|
||||||
.First(m => m.Name == "ApplyConfiguration" &&
|
.First(m => m.Name == nameof(ApplyConfiguration) &&
|
||||||
m.GetParameters().Any(p => p.ParameterType.GetGenericTypeDefinition() == typeof(IEntityTypeConfiguration<>)));
|
m.GetParameters().Any(p => p.ParameterType.GetGenericTypeDefinition() == typeof(IEntityTypeConfiguration<>)));
|
||||||
|
|
||||||
var entityType = configuration.GetType().GetInterfaces()
|
var entityType = configuration.GetType().GetInterfaces()
|
||||||
|
@ -4,8 +4,6 @@ namespace Mirea.Api.DataAccess.Persistence;
|
|||||||
|
|
||||||
public static class DbInitializer
|
public static class DbInitializer
|
||||||
{
|
{
|
||||||
public static void Initialize(DbContext dbContext)
|
public static void Initialize(DbContext dbContext) =>
|
||||||
{
|
|
||||||
dbContext.Database.Migrate();
|
dbContext.Database.Migrate();
|
||||||
}
|
|
||||||
}
|
}
|
Reference in New Issue
Block a user