Compare commits

...

91 Commits

Author SHA1 Message Date
95692a6a1f build: update ref
All checks were successful
.NET Test Pipeline / build (push) Successful in 2m5s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m55s
2025-03-11 09:37:58 +03:00
13eb3c0033 fix: remove convert universal time
All checks were successful
.NET Test Pipeline / build (push) Successful in 1m23s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m30s
2025-02-14 17:47:56 +03:00
f6d1543108 docs: fix code character
Some checks failed
.NET Test Pipeline / build (push) Has been cancelled
Build and Deploy Docker Container / build-and-deploy (push) Has been cancelled
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2025-02-13 16:40:33 +03:00
a4721c9739 build: add configuration Release
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m47s
.NET Test Pipeline / build (push) Successful in 1m51s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2025-02-12 09:35:55 +03:00
46bbc34956 refactor: fix error WHITESPACE and FINALNEWLINE
All checks were successful
.NET Test Pipeline / build (pull_request) Successful in 1m37s
.NET Test Pipeline / build (push) Successful in 1m33s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m18s
2025-02-11 17:13:13 +03:00
047ccfa754 fix: correct calculate next occurrence
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 44s
2025-02-11 16:35:56 +03:00
b0d9a67c1c refacotr: clean code 2025-02-11 15:36:55 +03:00
3eb043b24c build: fix code style with CRLF
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 42s
2025-02-11 15:29:43 +03:00
4cd476764d build: fix secrets
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 43s
2025-02-11 15:25:47 +03:00
90b4662dda Release 1.0.0
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 8s
2025-02-11 15:16:51 +03:00
e7edc79ebc build: instead build run analyze 2025-02-11 15:04:38 +03:00
aabeed0aa5 feat: add backend version to swagger 2025-02-10 16:07:51 +03:00
e79ec360ea Merge branch 'release/v1.0.0' of https://git.winsomnia.net/Winsomnia/MireaBackend into release/v1.0.0
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 1m36s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m37s
2025-02-06 16:29:48 +03:00
31c1d2804d fix: hotfix calculate next run time 2025-02-06 16:27:20 +03:00
ea4c8b61e0 refactor: use thread pool instead task
Some checks failed
.NET Test Pipeline / build-and-test (push) Has been cancelled
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m31s
2025-02-03 11:25:39 +03:00
b40e394bcf fix: System.ObjectDisposedException for db context into sync secrvice
Some checks failed
.NET Test Pipeline / build-and-test (push) Has been cancelled
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m45s
2025-02-03 10:55:47 +03:00
885b937b0b feat: add parsing from files
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 49s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m42s
2025-02-03 03:44:40 +03:00
dc08285ec8 feat: clear old records 2025-02-02 20:31:52 +03:00
b3a0964aac fix: correct filter data 2025-02-02 20:28:04 +03:00
7d6b21c5bb fix: move from body to query 2025-02-02 04:51:09 +03:00
93912caf01 fix: return correct value 2025-02-02 04:50:54 +03:00
c725cfed32 refactor: increase max value 2025-02-02 04:50:35 +03:00
7c7707b1e2 fix: if delay more than int set max of int 2025-02-02 04:50:04 +03:00
1687e9d89b fix: continue if in filter exist value 2025-02-02 04:49:25 +03:00
8d1b709b43 feat: add start term update and cron schedule update 2025-02-02 03:39:30 +03:00
ce6b0f2673 feat: add cron skipping date 2025-02-02 03:30:52 +03:00
16afc0bc69 feat: show enum name instead value 2025-02-02 03:29:19 +03:00
c9bc6a3565 refactor: remove "swagger" in class name 2025-02-02 03:28:24 +03:00
ad8f356fc1 fix: get non negative number 2025-02-02 01:57:08 +03:00
dda0a29300 refactor: subscribe to onChange instead of waiting for the event to be received from the manager 2025-02-01 21:23:51 +03:00
369901db78 fix: set long, because the value may be greater than int 2025-02-01 21:19:56 +03:00
a67b72b7fb refactor: rename cancellation to cancellationToken 2025-02-01 21:18:56 +03:00
2453b2bd51 build: upgrade ref 2025-02-01 20:47:25 +03:00
5870eef552 feat: add a tag schema to combine similar controllers. 2025-02-01 20:47:08 +03:00
52de98969d refactor: remove unused brackets 2025-02-01 20:45:08 +03:00
bc86e077bd refactor: move to SetupConfiguration namespace 2025-02-01 19:39:02 +03:00
03b6560bc4 feat: add lesson type controller 2025-02-01 17:08:00 +03:00
5bcb7bfbc1 feat: allow filter by lesson type 2025-02-01 17:06:02 +03:00
38fba5556f feat: add filter by type of occupation (lesson type) 2025-02-01 16:46:20 +03:00
fd26178a24 build: update ref
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 1m5s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m54s
2025-01-24 17:12:39 +03:00
7eb307b65e fix: return empty string if null 2025-01-24 17:10:46 +03:00
56c7196100 refactor: change const name to class with name 2025-01-24 17:10:18 +03:00
92081156cf fix: save token after update
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m23s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m13s
2024-12-28 08:34:19 +03:00
6358410f18 sec: to establish the ownership of the token for the first one who received it 2024-12-28 08:30:56 +03:00
e79ddf220f sec: set the absolute time of the token 2024-12-28 08:29:31 +03:00
c3c9844e2f refactor: improve logging 2024-12-28 08:29:06 +03:00
206720cd63 fix: add force select account 2024-12-28 08:16:00 +03:00
d9f4176aca fix: return message if 401 2024-12-28 08:15:43 +03:00
1de344ac25 refactor: to enable oauth during registration, use the appropriate controller. 2024-12-28 07:46:06 +03:00
61a11ea223 fix: return exception message if controller exception 2024-12-28 06:47:21 +03:00
07111b9b61 sec: do not return the error text to the user
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m40s
.NET Test Pipeline / build-and-test (push) Successful in 1m18s
2024-12-26 16:40:30 +03:00
538f1d67c8 fix: change the link to the error type 2024-12-26 16:39:29 +03:00
233458ed89 refactor: add standard traceId 2024-12-26 16:38:53 +03:00
7f87b4d856 style: add space between point and provider 2024-12-26 16:38:13 +03:00
0c6d1c9bfb refactor: compact two factor auth 2024-12-26 16:16:33 +03:00
516ba5bb8e feat: add a token handler 2024-12-26 16:14:55 +03:00
9d5007ef3a refactor: add user converter 2024-12-26 16:14:28 +03:00
c75ac60b0b sec: add verification for OAuth authorization 2024-12-26 15:47:38 +03:00
5b7412f20f feat: return the provider 2024-12-26 15:46:55 +03:00
c4a4478b8c refactor: standardize the order of arguments 2024-12-26 15:46:30 +03:00
05166188be feat: add a method for getting info about a token 2024-12-26 14:32:28 +03:00
157708d00f feat: store the result at each stage 2024-12-26 14:18:12 +03:00
36026b3afb refactor: distribute the domain folder 2024-12-26 13:38:43 +03:00
43edab2912 sec: return the token instead of performing actions with the user
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m0s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m53s
2024-12-26 08:51:22 +03:00
dcdd43469b feat: add payload model 2024-12-26 08:48:17 +03:00
17fd260068 sec: add payload 2024-12-26 08:47:56 +03:00
97187a8e45 sec: save readonly byte array instead string 2024-12-26 08:44:05 +03:00
cfe08dcf9b refactor: get interface instead array 2024-12-26 08:42:24 +03:00
ae4d2073c4 feat: add a 200 result schema 2024-12-25 07:22:07 +03:00
269d976ad4 refactor: move arguments to a new line 2024-12-25 05:54:27 +03:00
5fa545e981 fix: trim the variable to avoid the effects of whitespace characters 2024-12-25 05:53:59 +03:00
2ab5dea8ba feat: add a change to the User Agent and Ip address in case of a mismatch 2024-12-25 05:52:39 +03:00
5e65aded79 refactor: instead of Reason, add explicit arguments 2024-12-25 05:51:54 +03:00
dfac9ddca8 sec: add failed attempts for 2FA 2024-12-25 05:49:13 +03:00
c66f3355ec feat: add logging for empty secret 2024-12-25 05:48:22 +03:00
c12323dc29 refactor: rename methods to match the context 2024-12-25 05:47:51 +03:00
71c31c0bbb refactor: separate the method of counting failed attempts 2024-12-25 05:46:27 +03:00
8c51ba83a4 fix: add trim for email and username 2024-12-25 05:44:37 +03:00
9ff0f51e19 refactor: add data annotations 2024-12-25 05:44:15 +03:00
408a95e4b3 refactor: add .editorconfig and refactor code 2024-12-25 05:43:30 +03:00
2a33ecbf07 build: fix deploy script
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m19s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m14s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-12-23 16:54:05 +03:00
97e50b5331 fix: set minimum level for authorization and authentication to warning
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m31s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m47s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-12-23 10:53:32 +03:00
d505041c72 fix: escape data for state
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m38s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m9s
2024-12-23 08:10:19 +03:00
5ff8744a55 feat: improve logging
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m12s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m11s
2024-12-23 07:48:28 +03:00
053f01eec1 fix: hotfix getting current port
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m33s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m35s
2024-12-23 06:56:01 +03:00
e8e94e45a5 fix: add missing using
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m21s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m21s
2024-12-23 06:32:08 +03:00
55562a9f00 feat: add default response type
Some checks failed
Build and Deploy Docker Container / build-and-deploy (push) Failing after 1m30s
.NET Test Pipeline / build-and-test (push) Failing after 1m7s
2024-12-23 06:29:29 +03:00
57b9819d13 feat: add maintenance ignore 2024-12-23 06:29:00 +03:00
78254ed23d fix: change same name 2024-12-23 06:28:28 +03:00
202d20bb25 build: add providers 2024-12-23 06:28:06 +03:00
3e05863aea refactor: clean code 2024-12-22 07:25:41 +03:00
120 changed files with 2307 additions and 543 deletions

278
.editorconfig Normal file
View File

@ -0,0 +1,278 @@
# Удалите строку ниже, если вы хотите наследовать параметры .editorconfig из каталогов, расположенных выше в иерархии
root = true
# Файлы C#
[*.cs]
#### Основные параметры EditorConfig ####
# Отступы и интервалы
indent_size = 4
indent_style = space
tab_width = 4
# Предпочтения для новых строк
end_of_line = unset
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 = unset
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

View File

@ -1,29 +0,0 @@
name: .NET Test Pipeline
on:
pull_request:
push:
branches:
[master, 'release/*']
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build the solution
run: dotnet build --configuration Release
- name: Run tests
run: dotnet test --configuration Release --no-build --no-restore --verbosity normal

31
.github/workflows/code-analyze.yaml vendored Normal file
View File

@ -0,0 +1,31 @@
name: .NET Test Pipeline
on:
push:
branches:
- master
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checking out
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarScanner for .NET 8 with pull request decoration support
uses: highbyte/sonarscan-dotnet@v2.3.3
with:
sonarProjectKey: $(echo "${{ github.repository }}" | cut -d'/' -f2)
sonarProjectName: $(echo "${{ github.repository }}" | cut -d'/' -f2)
sonarHostname: ${{ secrets.SONAR_HOST_URL }}
dotnetPreBuildCmd: dotnet nuget add source --name="Winsomnia" --username ${{ secrets.NUGET_USERNAME }} --password ${{ secrets.NUGET_PASSWORD }} --store-password-in-clear-text ${{ secrets.NUGET_ADDRESS }} && dotnet format --verify-no-changes --diagnostics -v diag --severity warn
dotnetTestArguments: --logger trx --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
dotnetBuildArguments: -c Release
sonarBeginArguments: /d:sonar.cs.opencover.reportsPaths="**/TestResults/**/coverage.opencover.xml" -d:sonar.cs.vstest.reportsPaths="**/TestResults/*.trx"
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@ -1,9 +1,9 @@
name: Build and Deploy Docker Container
name: Build and Deploy Docker Container
on:
push:
branches:
[master, 'release/*']
[master]
jobs:
build-and-deploy:
@ -24,7 +24,7 @@ jobs:
- name: Build and push Docker image
run: |
docker build --build-arg NUGET_USERNAME=${{ secrets.NUGET_USERNAME }} --build-arg NUGET_PASSWORD=${{ secrets.NUGET_PASSWORD }} -t ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest .
docker build --build-arg NUGET_USERNAME=${{ secrets.NUGET_USERNAME }} --build-arg NUGET_PASSWORD=${{ secrets.NUGET_PASSWORD }} --build-arg NUGET_ADDRESS=${{ secrets.NUGET_ADDRESS }} -t ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest .
docker push ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest
- name: Start ssh-agent
@ -52,6 +52,12 @@ jobs:
SECURITY_HASH_SIZE: ${{ secrets.SECURITY_HASH_SIZE }}
SECURITY_HASH_TOKEN: ${{ secrets.SECURITY_HASH_TOKEN }}
SECURITY_SALT_SIZE: ${{ secrets.SECURITY_SALT_SIZE }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
YANDEX_CLIENT_ID: ${{ secrets.YANDEX_CLIENT_ID }}
YANDEX_CLIENT_SECRET: ${{ secrets.YANDEX_CLIENT_SECRET }}
MAILRU_CLIENT_ID: ${{ secrets.MAILRU_CLIENT_ID }}
MAILRU_CLIENT_SECRET: ${{ secrets.MAILRU_CLIENT_SECRET }}
run: |
ssh-keyscan $SSH_HOST >> ~/.ssh/known_hosts
ssh $SSH_USER@$SSH_HOST "
@ -78,6 +84,12 @@ jobs:
-e ACTUAL_SUB_PATH=api \
-e SWAGGER_SUB_PATH=swagger \
-e TZ=Europe/Moscow \
-e GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \
-e GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \
-e YANDEX_CLIENT_ID=$YANDEX_CLIENT_ID \
-e YANDEX_CLIENT_SECRET=$YANDEX_CLIENT_SECRET \
-e MAILRU_CLIENT_ID=$MAILRU_CLIENT_ID \
-e MAILRU_CLIENT_SECRET=$MAILRU_CLIENT_SECRET \
$DOCKER_IMAGE
"

View File

@ -0,0 +1,24 @@
using System;
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents a date or date range to skip during cron update scheduling.
/// </summary>
public class CronUpdateSkip
{
/// <summary>
/// Gets or sets the start date of the skip range.
/// </summary>
public DateOnly? Start { get; set; }
/// <summary>
/// Gets or sets the end date of the skip range.
/// </summary>
public DateOnly? End { get; set; }
/// <summary>
/// Gets or sets a specific date to skip.
/// </summary>
public DateOnly? Date { get; set; }
}

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Defines the actions that can be performed with an OAuth token.
/// </summary>
public enum OAuthAction
{
/// <summary>
/// The action to log in the user using the provided OAuth token.
/// </summary>
Login,
/// <summary>
/// The action to bind an OAuth provider to the user's account.
/// </summary>
Bind
}

View File

@ -19,4 +19,4 @@ public enum OAuthProvider
/// OAuth provider for Mail.ru.
/// </summary>
MailRu
}
}

View File

@ -29,4 +29,4 @@ public class PasswordPolicy
/// Gets or sets a value indicating whether at least one special character is required in the password.
/// </summary>
public bool RequireSpecialCharacter { get; set; }
}
}

View File

@ -14,4 +14,4 @@ public enum TwoFactorAuthentication
/// TOTP (Time-based One-Time Password) is required for additional verification.
/// </summary>
TotpRequired,
}
}

View File

@ -11,17 +11,20 @@ public class CreateUserRequest
/// Gets or sets the email address of the user.
/// </summary>
[Required]
[EmailAddress]
public required string Email { get; set; }
/// <summary>
/// Gets or sets the username of the user.
/// </summary>
[Required]
[MinLength(2)]
public required string Username { get; set; }
/// <summary>
/// Gets or sets the password of the user.
/// </summary>
[Required]
[MinLength(2)]
public required string Password { get; set; }
}
}

View File

@ -8,30 +8,30 @@ public class ScheduleRequest
/// <summary>
/// Gets or sets an array of group IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Groups { get; set; } = null;
/// <summary>
/// Gets or sets a value indicating whether to retrieve schedules for even weeks.
/// </summary>
/// <remarks>This property can contain null.</remarks>
public bool? IsEven { get; set; } = null;
/// <summary>
/// Gets or sets an array of discipline IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Disciplines { get; set; } = null;
/// <summary>
/// Gets or sets an array of professor IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Professors { get; set; } = null;
/// <summary>
/// Gets or sets an array of lecture hall IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? LectureHalls { get; set; } = null;
}
/// <summary>
/// Gets or sets an array of lesson type IDs.
/// </summary>
public int[]? LessonType { get; set; } = null;
}

View File

@ -28,4 +28,4 @@ public class CampusDetailsResponse
/// Gets or sets the address of the campus (optional).
/// </summary>
public string? Address { get; set; }
}
}

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses.Configuration;
/// <summary>
/// Represents the response containing the cron update schedule and the next scheduled task dates.
/// </summary>
public class CronUpdateScheduleResponse
{
/// <summary>
/// Gets or sets the cron expression representing the update schedule.
/// </summary>
[Required]
public required string Cron { get; set; }
/// <summary>
/// Gets or sets the list of next scheduled task dates based on the cron expression.
/// </summary>
[Required]
public required List<DateTime> NextStart { get; set; }
}

View File

@ -3,7 +3,7 @@
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents basic information about a faculty.
/// Represents information about a faculty.
/// </summary>
public class FacultyResponse
{

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents information about a lesson type.
/// </summary>
public class LessonTypeResponse
{
/// <summary>
/// Gets or sets the unique identifier of the lesson type.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the lesson type.
/// </summary>
[Required]
public required string Name { get; set; }
}

View File

@ -114,4 +114,4 @@ public class ScheduleResponse
/// Gets or sets the links to online meetings for the schedule entry.
/// </summary>
public required IEnumerable<string?> LinkToMeet { get; set; }
}
}

View File

@ -8,14 +8,15 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Elements of the solution", "Elements of the solution", "{3E087889-A4A0-4A55-A07D-7D149A5BC928}"
ProjectSection(SolutionItems) = preProject
.dockerignore = .dockerignore
.editorconfig = .editorconfig
.env = .env
.gitattributes = .gitattributes
.gitignore = .gitignore
.gitea\workflows\deploy-stage.yaml = .gitea\workflows\deploy-stage.yaml
.github\workflows\code-analyze.yaml = .github\workflows\code-analyze.yaml
Dockerfile = Dockerfile
LICENSE.txt = LICENSE.txt
README.md = README.md
.gitea\workflows\test.yaml = .gitea\workflows\test.yaml
.github\workflows\release-version.yml = .github\workflows\release-version.yml
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApiDto", "ApiDto\ApiDto.csproj", "{0335FA36-E137-453F-853B-916674C168FE}"

View File

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
LABEL company="Winsomnia"
LABEL maintainer.name="Wesser" maintainer.email="support@winsomnia.net"
WORKDIR /app
@ -13,10 +13,14 @@ COPY . .
ARG NUGET_USERNAME
ARG NUGET_PASSWORD
ARG NUGET_ADDRESS
ENV NUGET_USERNAME=$NUGET_USERNAME
ENV NUGET_PASSWORD=$NUGET_PASSWORD
ENV NUGET_ADDRESS=$NUGET_ADDRESS
RUN dotnet restore ./Backend.sln --configfile nuget.config
RUN dotnet nuget add source --name="Winsomnia" --username ${NUGET_USERNAME} --store-password-in-clear-text --password ${NUGET_PASSWORD} ${NUGET_ADDRESS}
RUN dotnet restore ./Backend.sln
WORKDIR /app
WORKDIR /src
RUN dotnet publish ./Endpoint/Endpoint.csproj -c Release --self-contained false -p:PublishSingleFile=false -o /app

View File

@ -2,7 +2,7 @@
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 int MaxAge { get; }

View File

@ -13,7 +13,7 @@ public class TokenAuthenticationAttribute : Attribute, IActionFilter
public void OnActionExecuting(ActionExecutingContext context)
{
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();
return;

View File

@ -3,6 +3,5 @@
public interface IMaintenanceModeNotConfigureService
{
bool IsMaintenanceMode { get; }
void DisableMaintenanceMode();
}

View File

@ -3,8 +3,6 @@
public interface IMaintenanceModeService
{
bool IsMaintenanceMode { get; }
void EnableMaintenanceMode();
void DisableMaintenanceMode();
}

View File

@ -17,7 +17,7 @@ public static class AvailableProvidersConverter
_ => 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()
{
ProviderName = Enum.GetName(x.Provider)!,

View File

@ -0,0 +1,19 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class CronUpdateSkipConverter
{
public static List<Dto.Common.CronUpdateSkip> ConvertToDto(this IEnumerable<ScheduleSettings.CronUpdateSkip> pairPeriod) =>
pairPeriod.Select(x => new Dto.Common.CronUpdateSkip()
{
Start = x.Start,
End = x.End,
Date = x.Date
}).ToList();
public static List<ScheduleSettings.CronUpdateSkip> ConvertFromDto(this IEnumerable<Dto.Common.CronUpdateSkip> pairPeriod) =>
pairPeriod.Select(x => x.Get()).ToList();
}

View File

@ -9,5 +9,6 @@ public static class PairPeriodTimeConverter
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 });
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));
}

View File

@ -1,17 +1,17 @@
using Mirea.Api.Dto.Common;
using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class PasswordPolicyConverter
{
public static Security.Common.Domain.PasswordPolicy ConvertFromDto(this PasswordPolicy policy) =>
public static Security.Common.Model.PasswordPolicy ConvertFromDto(this PasswordPolicy policy) =>
new(policy.MinimumLength,
policy.RequireLetter,
policy.RequireLettersDifferentCase,
policy.RequireDigit,
policy.RequireSpecialCharacter);
public static PasswordPolicy ConvertToDto(this Security.Common.Domain.PasswordPolicy policy) =>
public static PasswordPolicy ConvertToDto(this Security.Common.Model.PasswordPolicy policy) =>
new()
{
MinimumLength = policy.MinimumLength,

View File

@ -1,24 +1,23 @@
using Mirea.Api.Dto.Common;
using Mirea.Api.Security.Common.Domain;
using System;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class TwoFactorAuthenticationConverter
{
public static TwoFactorAuthentication ConvertToDto(this TwoFactorAuthenticator authenticator) =>
public static TwoFactorAuthentication ConvertToDto(this Security.Common.Model.TwoFactorAuthenticator authenticator) =>
authenticator switch
{
TwoFactorAuthenticator.None => TwoFactorAuthentication.None,
TwoFactorAuthenticator.Totp => TwoFactorAuthentication.TotpRequired,
Security.Common.Model.TwoFactorAuthenticator.None => TwoFactorAuthentication.None,
Security.Common.Model.TwoFactorAuthenticator.Totp => TwoFactorAuthentication.TotpRequired,
_ => throw new ArgumentOutOfRangeException(nameof(authenticator), authenticator, null)
};
public static TwoFactorAuthenticator ConvertFromDto(this TwoFactorAuthentication authentication) =>
public static Security.Common.Model.TwoFactorAuthenticator ConvertFromDto(this TwoFactorAuthentication authentication) =>
authentication switch
{
TwoFactorAuthentication.None => TwoFactorAuthenticator.None,
TwoFactorAuthentication.TotpRequired => TwoFactorAuthenticator.Totp,
TwoFactorAuthentication.None => Security.Common.Model.TwoFactorAuthenticator.None,
TwoFactorAuthentication.TotpRequired => Security.Common.Model.TwoFactorAuthenticator.Totp,
_ => throw new ArgumentOutOfRangeException(nameof(authentication), authentication, null)
};
}

View File

@ -0,0 +1,20 @@
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Security.Common.Model;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class UserConverter
{
public static User ConvertToSecurity(this Admin data) =>
new()
{
Id = 1.ToString(),
Email = data.Email,
Username = data.Username,
PasswordHash = data.PasswordHash,
Salt = data.Salt,
SecondFactorToken = data.Secret,
TwoFactorAuthenticator = data.TwoFactorAuthenticator,
OAuthProviders = data.OAuthProviders
};
}

View File

@ -0,0 +1,78 @@
using Cronos;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.Services;
public static class CronUpdateSkipService
{
public static ScheduleSettings.CronUpdateSkip Get(this Dto.Common.CronUpdateSkip date)
{
if (date.Date.HasValue)
return new ScheduleSettings.CronUpdateSkip(date.Date.Value);
if (date is { Start: not null, End: not null })
return new ScheduleSettings.CronUpdateSkip(date.Start.Value, date.End.Value);
throw new ArgumentException("It is impossible to create a structure because it has incorrect values.");
}
public static List<ScheduleSettings.CronUpdateSkip> FilterDateEntry(this List<ScheduleSettings.CronUpdateSkip> data, DateOnly? currentDate = null)
{
currentDate ??= DateOnly.FromDateTime(DateTime.Now);
return data.OrderBy(x => x.End ?? x.Date)
.Where(x => x.Date == currentDate || (x.Start <= currentDate && x.End >= currentDate))
.ToList();
}
public static List<ScheduleSettings.CronUpdateSkip> FilterDateEntry(this List<ScheduleSettings.CronUpdateSkip> data, DateTime? currentDate = null) =>
data.FilterDateEntry(DateOnly.FromDateTime(currentDate ?? DateTime.Now));
public static List<ScheduleSettings.CronUpdateSkip> Filter(this List<ScheduleSettings.CronUpdateSkip> data, DateOnly? currentDate = null)
{
currentDate ??= DateOnly.FromDateTime(DateTime.Now);
return data.Where(x => x.Date >= currentDate || x.End >= currentDate)
.OrderBy(x => x.End ?? x.Date)
.ToList();
}
public static List<DateTimeOffset> GetNextTask(this List<ScheduleSettings.CronUpdateSkip> data,
CronExpression expression, int depth = 1, DateOnly? currentDate = null)
{
if (depth <= 0)
return [];
DateTimeOffset nextRunTime = (currentDate?.ToDateTime(TimeOnly.MinValue) ?? DateTime.Now);
List<DateTimeOffset> result = [];
do
{
var lastSkippedEntry = data.FilterDateEntry(nextRunTime.DateTime).LastOrDefault();
if (lastSkippedEntry is { Start: not null, End: not null })
nextRunTime = lastSkippedEntry.End.Value.ToDateTime(TimeOnly.MinValue).AddDays(1);
else if (lastSkippedEntry.Date.HasValue)
nextRunTime = lastSkippedEntry.Date.Value.ToDateTime(TimeOnly.MinValue).AddDays(1);
var nextOccurrence = expression.GetNextOccurrence(nextRunTime.AddMinutes(-1), TimeZoneInfo.Local);
if (!nextOccurrence.HasValue)
return result;
if (data.FilterDateEntry(nextOccurrence.Value.DateTime).Count != 0)
{
nextRunTime = nextOccurrence.Value.AddDays(1);
continue;
}
result.Add(nextOccurrence.Value);
nextRunTime = nextOccurrence.Value.AddMinutes(1);
} while (result.Count < depth);
return result;
}
}

View File

@ -4,12 +4,7 @@ namespace Mirea.Api.Endpoint.Common.Services;
public static class ScheduleSyncManager
{
public static event Action? OnUpdateIntervalRequested;
public static event Action? OnForceSyncRequested;
public static void RequestIntervalUpdate() =>
OnUpdateIntervalRequested?.Invoke();
public static void RequestForceSync() =>
OnForceSyncRequested?.Invoke();
}

View File

@ -9,7 +9,8 @@ namespace Mirea.Api.Endpoint.Common.Services.Security;
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
{

View File

@ -9,7 +9,8 @@ namespace Mirea.Api.Endpoint.Common.Services.Security;
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
{

View File

@ -11,6 +11,9 @@ public static class UrlHelper
public static string GetCurrentDomain(this HttpContext context) =>
context.Request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? context.Request.Host.Host;
public static int? GetCurrentPort(this HttpContext context) =>
string.IsNullOrEmpty(context.Request.Headers["X-Forwarded-Port"].FirstOrDefault()) ? context.Request.Host.Port :
int.Parse(context.Request.Headers["X-Forwarded-Port"].First()!);
private static string CreateSubPath(string? path)
{
@ -47,7 +50,7 @@ public static class UrlHelper
var scheme = GetCurrentScheme(context);
var domain = GetCurrentDomain(context).TrimEnd('/').Replace("localhost", "127.0.0.1");
var port = context.Request.Host.Port;
var port = GetCurrentPort(context);
var portString = port.HasValue && port != 80 && port != 443 ? $":{port}" : string.Empty;
return $"{scheme}://{domain}{portString}{GetSubPathWithoutFirstApiName}{apiPath.Trim('/')}";

View File

@ -5,8 +5,11 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Sync;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -15,24 +18,47 @@ namespace Mirea.Api.Endpoint.Configuration.Core.BackgroundTasks;
public class ScheduleSyncService : IHostedService, IDisposable
{
private Timer? _timer;
private readonly IOptionsMonitor<GeneralConfig> _generalConfigMonitor;
private string _cronUpdate;
private List<ScheduleSettings.CronUpdateSkip> _cronUpdateSkip;
private readonly ILogger<ScheduleSyncService> _logger;
private CancellationTokenSource _cancellationTokenSource = new();
private readonly IServiceProvider _serviceProvider;
private readonly IDisposable? _onChangeUpdateCron;
public ScheduleSyncService(IOptionsMonitor<GeneralConfig> generalConfigMonitor, ILogger<ScheduleSyncService> logger, IServiceProvider serviceProvider)
{
_generalConfigMonitor = generalConfigMonitor;
_logger = logger;
_serviceProvider = serviceProvider;
_cronUpdate = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSchedule;
_cronUpdateSkip = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSkipDateList;
ScheduleSyncManager.OnForceSyncRequested += OnForceSyncRequested;
ScheduleSyncManager.OnUpdateIntervalRequested += OnUpdateIntervalRequested;
_onChangeUpdateCron = generalConfigMonitor.OnChange((config) =>
{
var updated = false;
if (config.ScheduleSettings?.CronUpdateSchedule != null && _cronUpdate != config.ScheduleSettings.CronUpdateSchedule)
{
_cronUpdate = config.ScheduleSettings.CronUpdateSchedule;
updated = true;
}
if (config.ScheduleSettings?.CronUpdateSkipDateList != null && !config.ScheduleSettings.CronUpdateSkipDateList.SequenceEqual(_cronUpdateSkip))
{
_cronUpdateSkip = config.ScheduleSettings.CronUpdateSkipDateList
.OrderBy(x => x.End ?? x.Date)
.ToList();
updated = true;
}
if (updated)
OnUpdateIntervalRequested();
});
}
private void OnForceSyncRequested()
{
StopAsync(default).ContinueWith(_ =>
_logger.LogInformation("It was requested to synchronize the data immediately.");
StopAsync(CancellationToken.None).ContinueWith(_ =>
{
_cancellationTokenSource = new CancellationTokenSource();
ExecuteTask(null);
@ -41,39 +67,42 @@ public class ScheduleSyncService : IHostedService, IDisposable
private void OnUpdateIntervalRequested()
{
StopAsync(default).ContinueWith(_ =>
_logger.LogInformation("It was requested to update the time interval immediately.");
StopAsync(CancellationToken.None).ContinueWith(_ =>
{
StartAsync(default);
StartAsync(CancellationToken.None);
});
}
private void ScheduleNextRun()
{
var cronExpression = _generalConfigMonitor.CurrentValue.ScheduleSettings?.CronUpdateSchedule;
if (string.IsNullOrEmpty(cronExpression))
if (string.IsNullOrEmpty(_cronUpdate))
{
_logger.LogWarning("Cron expression is not set. The scheduled task will not run.");
return;
}
var nextRunTime = CronExpression.Parse(cronExpression).GetNextOccurrence(DateTimeOffset.Now, TimeZoneInfo.Local);
var expression = CronExpression.Parse(_cronUpdate);
if (!nextRunTime.HasValue)
var nextRunTime = _cronUpdateSkip.GetNextTask(expression).FirstOrDefault();
if (nextRunTime == default)
{
_logger.LogWarning("No next run time found. The task will not be scheduled. Timezone: {TimeZone}", TimeZoneInfo.Local.DisplayName);
_logger.LogWarning("No next run time found. The task will not be scheduled. Timezone: {TimeZone}",
TimeZoneInfo.Local.DisplayName);
return;
}
_logger.LogInformation("Next task run in {Time}", nextRunTime.Value.ToString("G"));
_logger.LogInformation("Next task run in {Time}", nextRunTime.ToString("G"));
var delay = (nextRunTime.Value - DateTimeOffset.Now).TotalMilliseconds;
var delay = (nextRunTime - DateTimeOffset.Now).TotalMilliseconds;
// The chance is small, but it's better to check
if (delay <= 0)
delay = 1;
_cancellationTokenSource = new CancellationTokenSource();
_timer = new Timer(ExecuteTask, null, (int)delay, Timeout.Infinite);
_timer = new Timer(ExecuteTask, null, delay > int.MaxValue ? int.MaxValue : (int)delay, Timeout.Infinite);
}
private async void ExecuteTask(object? state)
@ -109,10 +138,10 @@ public class ScheduleSyncService : IHostedService, IDisposable
public void Dispose()
{
StopAsync(default).GetAwaiter().GetResult();
StopAsync(CancellationToken.None).GetAwaiter().GetResult();
_timer?.Dispose();
ScheduleSyncManager.OnForceSyncRequested -= OnForceSyncRequested;
ScheduleSyncManager.OnUpdateIntervalRequested -= OnUpdateIntervalRequested;
_onChangeUpdateCron?.Dispose();
_cancellationTokenSource.Dispose();
GC.SuppressFinalize(this);

View File

@ -34,10 +34,10 @@ public class CustomExceptionHandlerMiddleware(RequestDelegate next, ILogger<Cust
var problemDetails = new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.6",
Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
Title = "An unexpected error occurred.",
Status = StatusCodes.Status500InternalServerError,
Detail = exception.Message,
Detail = "Please provide this traceId to the administrator for further investigation.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", traceId }
@ -65,11 +65,13 @@ public class CustomExceptionHandlerMiddleware(RequestDelegate next, ILogger<Cust
problemDetails.Status = StatusCodes.Status400BadRequest;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1";
problemDetails.Title = "Invalid arguments provided.";
problemDetails.Detail = exception.Message;
break;
case SecurityException:
problemDetails.Status = StatusCodes.Status401Unauthorized;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
problemDetails.Title = "Unauthorized access.";
problemDetails.Detail = exception.Message;
break;
case ServerUnavailableException unavailableException:
problemDetails.Status = StatusCodes.Status503ServiceUnavailable;

View File

@ -21,7 +21,7 @@ public static class EnvironmentConfiguration
var commentIndex = line.IndexOf('#', StringComparison.Ordinal);
string arg = line;
var arg = line;
if (commentIndex != -1)
arg = arg.Remove(commentIndex, arg.Length - commentIndex);

View File

@ -19,12 +19,14 @@ public static class JwtConfiguration
var jwtDecrypt = Encoding.UTF8.GetBytes(configuration["SECURITY_ENCRYPTION_TOKEN"] ?? string.Empty);
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);
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 jwtAudience = configuration["SECURITY_JWT_AUDIENCE"];
@ -62,4 +64,4 @@ public static class JwtConfiguration
};
});
}
}
}

View File

@ -53,7 +53,9 @@ public static class LoggerConfiguration
configuration
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning);
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Authentication", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Authorization", LogEventLevel.Warning);
configuration.Filter.ByExcluding(Matching.WithProperty<string>("SourceContext", sc =>
sc.Contains("Microsoft.EntityFrameworkCore.Database.Command")));
@ -65,7 +67,10 @@ public static class LoggerConfiguration
return app.Use(async (context, next) =>
{
var traceId = Activity.Current?.Id ?? context.TraceIdentifier;
using (LogContext.PushProperty("TraceId", traceId))
using (LogContext.PushProperty("UserAgent", context.Request.Headers.UserAgent.ToString()))
using (LogContext.PushProperty("RemoteIPAddress", context.Connection.RemoteIpAddress?.ToString()))
{
await next();
}
@ -90,8 +95,8 @@ public static class LoggerConfiguration
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent);
diagnosticContext.Set("RemoteIPAddress", httpContext.Connection.RemoteIpAddress?.ToString());
diagnosticContext.Set("RemoteIPAddress", httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty);
};
});
}
}
}

View File

@ -17,8 +17,11 @@ public static class SwaggerConfiguration
{
services.AddSwaggerGen(options =>
{
options.SchemaFilter<SwaggerExampleFilter>();
options.OperationFilter<SwaggerDefaultValues>();
options.OperationFilter<TagSchemeFilter>();
options.SchemaFilter<ExampleFilter>();
options.OperationFilter<DefaultValues>();
options.OperationFilter<ActionResultSchemaFilter>();
options.SchemaFilter<EnumSchemaFilter>();
var basePath = AppDomain.CurrentDomain.BaseDirectory;
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme

View File

@ -2,4 +2,4 @@
public interface ISaveSettings
{
void SaveSetting();
}
}

View File

@ -1,5 +1,6 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Model;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
@ -10,12 +11,22 @@ namespace Mirea.Api.Endpoint.Configuration.Model;
public class Admin : ISaveSettings
{
[JsonIgnore] private const string FileName = "admin.json";
private string _username = string.Empty;
private string _email = string.Empty;
[JsonIgnore]
public static string FilePath => PathBuilder.Combine(FileName);
public required string Username { get; set; }
public required string Email { get; set; }
public required string Username
{
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 Salt { get; set; }
public TwoFactorAuthenticator TwoFactorAuthenticator { get; set; } = TwoFactorAuthenticator.None;

View File

@ -1,6 +1,6 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Model;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;

View File

@ -31,9 +31,33 @@ public class ScheduleSettings : IIsConfigured
public PairPeriodTime(Dto.Common.PairPeriodTime time) : this(time.Start, time.End) { }
}
public record struct CronUpdateSkip
{
public DateOnly? Start { get; set; }
public DateOnly? End { get; set; }
public DateOnly? Date { get; set; }
public CronUpdateSkip(DateOnly d1, DateOnly d2)
{
if (d1 > d2)
{
Start = d2;
End = d1;
}
else
{
Start = d1;
End = d2;
}
}
public CronUpdateSkip(DateOnly d1) => Date = d1;
}
public required string CronUpdateSchedule { get; set; }
public DateOnly StartTerm { get; set; }
public required IDictionary<int, PairPeriodTime> PairPeriod { get; set; }
public List<CronUpdateSkip> CronUpdateSkipDateList { get; set; } = [];
public bool IsConfigured()
{

View File

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

View File

@ -4,6 +4,8 @@ using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.Diagnostics;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
@ -12,18 +14,17 @@ public class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) :
public void Configure(SwaggerGenOptions options)
{
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description));
}
}
private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description)
{
var info = new OpenApiInfo()
{
Title = "MIREA Schedule Web API",
Title = $"MIREA Schedule Web API ({FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).ProductVersion})",
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" },
License = new OpenApiLicense { Name = "MIT", Url = new Uri("https://opensource.org/licenses/MIT") }
};

View File

@ -8,7 +8,7 @@ using System.Text.Json;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerDefaultValues : IOperationFilter
public class DefaultValues : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
@ -23,16 +23,12 @@ public class SwaggerDefaultValues : IOperationFilter
foreach (var contentType in response.Content.Keys)
{
if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType))
{
response.Content.Remove(contentType);
}
}
}
if (operation.Parameters == null)
{
return;
}
foreach (var parameter in operation.Parameters)
{

View File

@ -0,0 +1,28 @@
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.Linq;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class EnumSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
if (!context.Type.IsEnum)
return;
schema.Enum.Clear();
var enumValues = Enum.GetNames(context.Type)
.Select(name => new OpenApiString(name))
.ToList();
foreach (var value in enumValues)
schema.Enum.Add(value);
schema.Type = "string";
schema.Format = null;
}
}

View File

@ -5,7 +5,7 @@ using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerExampleFilter : ISchemaFilter
public class ExampleFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{

View File

@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Linq;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class TagSchemeFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (context.ApiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
return;
var controllerType = controllerActionDescriptor.ControllerTypeInfo;
var tagsAttribute = controllerType.GetCustomAttributes<TagsAttribute>(inherit: true).FirstOrDefault();
if (tagsAttribute == null)
{
var baseType = controllerType.BaseType;
while (baseType != null)
{
tagsAttribute = baseType.GetCustomAttributes<TagsAttribute>(inherit: true).FirstOrDefault();
if (tagsAttribute != null)
break;
baseType = baseType.BaseType;
}
}
if (tagsAttribute == null)
return;
operation.Tags ??= [];
operation.Tags.Add(new OpenApiTag { Name = tagsAttribute.Tags[0] });
}
}

View File

@ -14,8 +14,8 @@ public class SetupTokenService : ISetupToken
var token2 = Token.Value.Span;
int result = 0;
for (int i = 0; i < Token.Value.Length; i++)
var result = 0;
for (var i = 0; i < Token.Value.Length; i++)
result |= token2[i] ^ token[i];
return result == 0;

View File

@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Mirea.Api.Endpoint.Controllers;
[Route("api/v{version:apiVersion}/Configuration/[controller]")]
[Authorize]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Tags("Configuration")]
public class ConfigurationBaseController : BaseController;

View File

@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Requests.Configuration;
@ -19,6 +18,7 @@ using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Configuration.Validation.Validators;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Model;
using Mirea.Api.Security.Services;
using MySqlConnector;
using Npgsql;
@ -26,16 +26,20 @@ using Serilog;
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Mail;
using System.Runtime.InteropServices;
using System.Security;
using System.Security.Cryptography;
using System.Threading.Tasks;
using CookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy;
namespace Mirea.Api.Endpoint.Controllers.Configuration;
namespace Mirea.Api.Endpoint.Controllers.SetupConfiguration;
[ApiVersion("1.0")]
[MaintenanceModeIgnore]
@ -45,7 +49,7 @@ public class SetupController(
IMaintenanceModeNotConfigureService notConfigureService,
IMemoryCache cache,
PasswordHashService passwordHashService,
IOptionsSnapshot<Admin> user) : BaseController
OAuthService oAuthService) : BaseController
{
private const string CacheGeneralKey = "config_general";
private const string CacheAdminKey = "config_admin";
@ -144,7 +148,7 @@ public class SetupController(
[BadRequestResponse]
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)
connectionString += $";Password={request.Password}";
if (request.Ssl)
@ -171,7 +175,7 @@ public class SetupController(
[BadRequestResponse]
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)
connectionString += $"Pwd={request.Password};";
if (request.Ssl)
@ -241,7 +245,7 @@ public class SetupController(
[BadRequestResponse]
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)
connectionString += $",password={request.Password}";
@ -302,19 +306,15 @@ public class SetupController(
[HttpPost("CreateAdmin")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<string> CreateAdmin([FromBody] CreateUserRequest user)
public ActionResult<string> CreateAdmin([FromBody] CreateUserRequest userRequest)
{
new PasswordPolicyService(GeneralConfig.PasswordPolicy).ValidatePasswordOrThrow(user.Password);
if (!MailAddress.TryCreate(user.Email, out _))
throw new ControllerArgumentException("The email address is incorrect.");
var (salt, hash) = passwordHashService.HashPassword(user.Password);
new PasswordPolicyService(GeneralConfig.PasswordPolicy).ValidatePasswordOrThrow(userRequest.Password);
var (salt, hash) = passwordHashService.HashPassword(userRequest.Password);
var admin = new Admin
{
Username = user.Username,
Email = user.Email,
Username = userRequest.Username,
Email = userRequest.Email,
PasswordHash = hash,
Salt = salt
};
@ -323,29 +323,54 @@ public class SetupController(
return Ok(true);
}
[HttpGet("UpdateAdminConfiguration")]
[HttpGet("HandleToken")]
[TokenAuthentication]
public ActionResult UpdateAdminConfiguration()
public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token)
{
if (string.IsNullOrEmpty(user.Value.Email))
return Ok();
var (user, error, isSuccess, provider) = await oAuthService.GetOAuthUser(new Security.Common.Model.CookieOptions
{
Domain = HttpContext.GetCurrentDomain(),
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
}, HttpContext, token);
if (!isSuccess || user == null || provider == null)
throw new ControllerArgumentException(error ?? "Token processing error.");
if (!cache.TryGetValue<Admin>(CacheAdminKey, out var admin))
{
admin = user.Value;
admin = new Admin()
{
Email = user.Email ?? string.Empty,
Username = user.Username ?? string.Empty,
PasswordHash = string.Empty,
Salt = string.Empty,
OAuthProviders = new Dictionary<OAuthProvider, OAuthUser>
{
{provider.Value, user}
}
};
cache.Set(CacheAdminKey, admin);
return Ok();
}
admin!.OAuthProviders = user.Value.OAuthProviders;
if (string.IsNullOrEmpty(admin.Email))
admin.Email = user.Value.Email;
if (string.IsNullOrEmpty(admin.Username))
admin.Username = user.Value.Username;
if (admin!.OAuthProviders != null && admin.OAuthProviders.ContainsKey(provider.Value))
return Conflict(new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10",
Title = "Conflict",
Status = StatusCodes.Status409Conflict,
Detail = "This OAuth provider is already associated with the account.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
admin.OAuthProviders ??= [];
admin.OAuthProviders.Add(provider.Value, user);
cache.Set(CacheAdminKey, admin);
return Ok();
}
@ -544,7 +569,7 @@ public class SetupController(
[TokenAuthentication]
public ActionResult<bool> SetPasswordPolicy([FromBody] PasswordPolicy? policy = null)
{
GeneralConfig.PasswordPolicy = policy?.ConvertFromDto() ?? new Security.Common.Domain.PasswordPolicy();
GeneralConfig.PasswordPolicy = policy?.ConvertFromDto() ?? new Security.Common.Model.PasswordPolicy();
cache.Set("password", true);
return true;
}

View File

@ -11,115 +11,110 @@ using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Services;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using CookieOptions = Mirea.Api.Security.Common.Model.CookieOptions;
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
namespace Mirea.Api.Endpoint.Controllers.V1;
[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 CookieOptions GetCookieParams() =>
new()
{
Domain = HttpContext.GetCurrentDomain(),
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";
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>";
var callbackUrl = callback?.ToString();
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>";
}
/// <summary>
/// Handles the callback from an OAuth2 provider and finalizes the authorization process.
/// </summary>
/// <remarks>
/// This method processes the response from an OAuth provider after the user authorizes the application.
/// Upon successful authorization, it redirects the user back to the specified callback URL.
/// </remarks>
/// <param name="code">The authorization code returned by the OAuth provider.</param>
/// <param name="state">The state parameter to ensure the request's integrity and prevent CSRF attacks.</param>
/// <returns>
/// An HTML response indicating the success or failure of the authorization process.
/// If a callback URL is provided, the user will be redirected to it.
/// </returns>
[HttpGet("OAuth2")]
[BadRequestResponse]
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
[Produces("text/html")]
[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;
try
{
(provider, oAuthUser) = await oAuthService.LoginOAuth(HttpContext, GetCookieParams(),
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
return Content(GenerateHtmlResponse(
"Ошибка передачи данных!",
"Провайдер OAuth не передал нужных данных.",
null,
traceId,
true), "text/html");
var result = await oAuthService.LoginOAuth(GetCookieParams(), HttpContext,
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!";
message = e.Message;
return Content(GenerateHtmlResponse(title, message, null, true, traceId), "text/html");
if (callbackUrl != null)
callbackUrl += $"&traceId={Uri.EscapeDataString(traceId)}";
title = "Ошибка авторизации!";
message = result.ErrorMessage ?? "Произошла ошибка. Попробуйте ещё раз.";
}
var userEntity = user.Value;
if (userId != null)
else
{
userEntity.OAuthProviders ??= new Dictionary<OAuthProvider, OAuthUser>();
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");
title = "Авторизация завершена!";
message = "Вы будете перенаправлены обратно через несколько секунд.";
}
if (userEntity.OAuthProviders != null &&
userEntity.OAuthProviders.TryGetValue(provider, out var userOAuth) &&
userOAuth.Id == oAuthUser.Id)
{
await auth.LoginOAuthAsync(GetCookieParams(), HttpContext, new User
{
Id = 1.ToString(),
Username = userEntity.Username,
Email = userEntity.Email,
PasswordHash = userEntity.PasswordHash,
Salt = userEntity.Salt,
TwoFactorAuthenticator = userEntity.TwoFactorAuthenticator,
SecondFactorToken = userEntity.Secret,
OAuthProviders = userEntity.OAuthProviders
}, provider);
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");
return Content(GenerateHtmlResponse(
title,
message,
callbackUrl == null ? null : new Uri(callbackUrl),
traceId,
!result.Success), "text/html");
}
/// <summary>
@ -129,16 +124,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.
/// </remarks>
/// <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>
/// <exception cref="ControllerArgumentException">Thrown if the specified provider is not valid.</exception>
[HttpGet("AuthorizeOAuth2")]
[MaintenanceModeIgnore]
public ActionResult AuthorizeOAuth2([FromQuery] int provider)
public ActionResult AuthorizeOAuth2([FromQuery] int provider, [FromQuery] Uri callback)
{
if (!Enum.IsDefined(typeof(OAuthProvider), 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(GetCookieParams(), HttpContext,
HttpContext.GetApiUrl(Url.Action("OAuth2")!),
(OAuthProvider)provider,
callback).AbsoluteUri);
}
/// <summary>
@ -150,11 +152,79 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
/// <returns>A list of available providers and their redirect URLs.</returns>
[HttpGet("AvailableProviders")]
[MaintenanceModeIgnore]
public ActionResult<List<AvailableOAuthProvidersResponse>> AvailableProviders() =>
public ActionResult<List<AvailableOAuthProvidersResponse>> AvailableProviders([FromQuery] Uri callback) =>
Ok(oAuthService
.GetAvailableProviders(HttpContext, 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());
/// <summary>
/// Processes the OAuth token
/// </summary>
/// <param name="token">The OAuth token used for authentication or binding.</param>
/// <param name="action">The action to be performed: Login or Bind.</param>
/// <returns>If <see cref="OAuthAction.Bind"/> return Ok. If <see cref="OAuthAction.Login"/> return <see cref="TwoFactorAuthentication"/></returns>
[HttpGet("HandleToken")]
[BadRequestResponse]
public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token, [FromQuery] OAuthAction action)
{
var (oAuthUser, error, isSuccess, provider) = await oAuthService.GetOAuthUser(GetCookieParams(), HttpContext, token);
if (!isSuccess || oAuthUser == null || provider == null)
throw new ControllerArgumentException(error ?? "Token processing error.");
switch (action)
{
case OAuthAction.Login:
return Ok(await auth.LoginOAuthAsync(GetCookieParams(), HttpContext, user.Value.ConvertToSecurity(), oAuthUser, provider.Value));
case OAuthAction.Bind:
var userId = HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
var admin = user.Value;
if (string.IsNullOrEmpty(userId) || !int.TryParse(userId, out var result) || result != 1)
return Unauthorized(new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2",
Title = "Unauthorized",
Status = StatusCodes.Status401Unauthorized,
Detail = "The user is not logged in to link accounts.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
if (admin.OAuthProviders != null && admin.OAuthProviders.ContainsKey(provider.Value))
return Conflict(new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10",
Title = "Conflict",
Status = StatusCodes.Status409Conflict,
Detail = "This OAuth provider is already associated with the account.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
admin.OAuthProviders ??= [];
admin.OAuthProviders.Add(provider.Value, oAuthUser);
admin.SaveSetting();
return Ok();
default:
throw new ControllerArgumentException("The action cannot be processed.");
}
}
/// <summary>
/// Logs in a user using their username or email and password.
/// </summary>
@ -168,18 +238,9 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
var tokenResult = await auth.LoginAsync(
GetCookieParams(),
new User
{
Id = 1.ToString(),
Username = userEntity.Username,
Email = userEntity.Email,
PasswordHash = userEntity.PasswordHash,
Salt = userEntity.Salt,
TwoFactorAuthenticator = userEntity.TwoFactorAuthenticator,
SecondFactorToken = userEntity.Secret,
OAuthProviders = userEntity.OAuthProviders
},
HttpContext, request.Password, request.Username);
HttpContext,
userEntity.ConvertToSecurity(),
request.Password, request.Username);
return Ok(tokenResult.ConvertToDto());
}
@ -191,11 +252,8 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
/// <returns>A boolean indicating whether the two-factor authentication was successful.</returns>
[HttpPost("2FA")]
[BadRequestResponse]
public async Task<ActionResult<bool>> TwoFactorAuth([FromBody] TwoFactorAuthRequest request)
{
var tokenResult = await auth.LoginAsync(GetCookieParams(), HttpContext, request.Method.ConvertFromDto(), request.Code);
return Ok(tokenResult);
}
public async Task<ActionResult<bool>> TwoFactorAuth([FromBody] TwoFactorAuthRequest request) =>
await auth.LoginAsync(GetCookieParams(), HttpContext, request.Method.ConvertFromDto(), request.Code);
/// <summary>
/// Refreshes the authentication token using the existing refresh token.
@ -257,4 +315,4 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
return Ok(password);
}
}
}

View File

@ -0,0 +1,219 @@
using Asp.Versioning;
using Cronos;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mirea.Api.DataAccess.Persistence;
using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Responses.Configuration;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Sync;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1.Configuration;
[ApiVersion("1.0")]
public class ScheduleController(ILogger<ScheduleController> logger, IOptionsSnapshot<GeneralConfig> config, UberDbContext dbContext, IServiceProvider provider) : ConfigurationBaseController
{
/// <summary>
/// Retrieves the cron update schedule and calculates the next scheduled tasks based on the provided depth.
/// </summary>
/// <param name="depth">The depth of the next tasks to retrieve.</param>
/// <returns>Cron expression and the list of next scheduled task dates.</returns>
[HttpGet("CronUpdateSchedule")]
public ActionResult<CronUpdateScheduleResponse> CronUpdateSchedule([FromQuery][Range(0, 10)] int depth = 5)
{
var cronExpression = CronExpression.Parse(config.Value.ScheduleSettings!.CronUpdateSchedule);
var nextTasks = config.Value.ScheduleSettings!.CronUpdateSkipDateList.GetNextTask(cronExpression, depth);
return new CronUpdateScheduleResponse()
{
Cron = config.Value.ScheduleSettings!.CronUpdateSchedule,
NextStart = nextTasks.Select(x => DateTime.SpecifyKind(x.DateTime, DateTimeKind.Local)).ToList()
};
}
/// <summary>
/// Updates the cron update schedule with the provided cron expression.
/// </summary>
/// <param name="cron">The cron expression to set as the new schedule.</param>
/// <returns>Cron expression and the list of next scheduled task dates.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the provided cron expression is invalid.</exception>
[HttpPost("CronUpdateSchedule")]
public ActionResult<CronUpdateScheduleResponse> CronUpdateSchedule([FromQuery] string cron)
{
cron = cron.Trim();
if (!CronExpression.TryParse(cron, CronFormat.Standard, out _))
throw new ControllerArgumentException("Incorrect cron value.");
if (config.Value.ScheduleSettings!.CronUpdateSchedule == cron)
return CronUpdateSchedule();
config.Value.ScheduleSettings!.CronUpdateSchedule = cron;
config.Value.SaveSetting();
return CronUpdateSchedule();
}
/// <summary>
/// Retrieves the start term date from the configuration.
/// </summary>
/// <returns>Start term date.</returns>
[HttpGet("StartTerm")]
public ActionResult<DateOnly> StartTerm() =>
config.Value.ScheduleSettings!.StartTerm;
/// <summary>
/// Updates the start term date in the configuration.
/// </summary>
/// <param name="startTerm">The new start term date to set.</param>
/// <param name="force">If true, forces an update by deleting all existing lessons.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the start term date is more than 6 months in the past or future.</exception>
[HttpPost("StartTerm")]
public ActionResult StartTerm([FromQuery] DateOnly startTerm, [FromQuery] bool force = false)
{
var differentByTime = DateTime.Now - startTerm.ToDateTime(new TimeOnly(0, 0, 0));
if (differentByTime > TimeSpan.FromDays(190) || differentByTime.Multiply(-1) > TimeSpan.FromDays(190))
throw new ControllerArgumentException("The semester can't start more than 6 months from now, and it can't have started more than 6 months ago either.");
config.Value.ScheduleSettings!.StartTerm = startTerm;
config.Value.SaveSetting();
if (!force)
return Ok();
logger.LogWarning("A force update is being performed at the beginning of the semester (all classes will be deleted).");
dbContext.Lessons.RemoveRange(dbContext.Lessons.ToList());
dbContext.SaveChanges();
return Ok();
}
/// <summary>
/// Retrieves the list of cron update skip dates filtered by the current date.
/// </summary>
/// <returns>Cron update skip dates.</returns>
[HttpGet("CronUpdateSkip")]
public ActionResult<List<CronUpdateSkip>> CronUpdateSkip()
{
var generalConfig = config.Value;
generalConfig.ScheduleSettings!.CronUpdateSkipDateList =
generalConfig.ScheduleSettings.CronUpdateSkipDateList.Filter();
generalConfig.SaveSetting();
return generalConfig.ScheduleSettings!.CronUpdateSkipDateList
.ConvertToDto();
}
/// <summary>
/// Updates the list of cron update skip dates in the configuration.
/// </summary>
/// <param name="cronUpdateDate">The list of cron update skip dates to set.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the provided list of cron update skip dates is invalid.</exception>
[HttpPost("CronUpdateSkip")]
public ActionResult CronUpdateSkip([FromBody] List<CronUpdateSkip> cronUpdateDate)
{
List<ScheduleSettings.CronUpdateSkip> result;
try
{
result = cronUpdateDate.ConvertFromDto();
}
catch (ArgumentException ex)
{
throw new ControllerArgumentException(ex.Message);
}
config.Value.ScheduleSettings!.CronUpdateSkipDateList = result.Filter();
config.Value.SaveSetting();
return Ok();
}
/// <summary>
/// Uploads schedule files and initiates synchronization.
/// </summary>
/// <param name="files">The list of schedule files to upload.</param>
/// <param name="defaultCampus">The default campus for each uploaded file. Must match the number of files.</param>
/// <param name="force">If true, removes all existing lessons before synchronization. Default is false.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">
/// Thrown if:
/// - No files are provided.
/// - The number of default campuses does not match the number of files.
/// - Any default campus is null or empty.
/// </exception>
[HttpPost("Upload")]
public async Task<ActionResult> UploadScheduleFiles(List<IFormFile>? files, [FromQuery] string[]? defaultCampus, [FromQuery] bool force = false)
{
if (files == null || files.Count == 0)
throw new ControllerArgumentException("No files were found.");
if (defaultCampus == null || files.Count != defaultCampus.Length)
throw new ControllerArgumentException("No default campuses are specified for the file.");
if (defaultCampus.Any(string.IsNullOrEmpty))
throw new ControllerArgumentException("Each file should have a default campus.");
var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
if (!Directory.Exists(tempDirectory))
Directory.CreateDirectory(tempDirectory);
List<(string, string)> filePaths = [];
for (var i = 0; i < files.Count; i++)
{
if (files[i].Length <= 0)
continue;
var filePath = Path.Combine(tempDirectory, files[i].FileName);
await using var stream = new FileStream(filePath, FileMode.Create);
await files[i].CopyToAsync(stream);
filePaths.Add((filePath, defaultCampus[i]));
}
if (force)
{
dbContext.Lessons.RemoveRange(await dbContext.Lessons.ToListAsync());
await dbContext.SaveChangesAsync();
}
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
ThreadPool.QueueUserWorkItem(async void (_) =>
{
try
{
using var scope = scopeFactory.CreateScope();
var sync = (ScheduleSynchronizer)ActivatorUtilities.GetServiceOrCreateInstance(scope.ServiceProvider, typeof(ScheduleSynchronizer));
await sync.StartSync(filePaths, CancellationToken.None);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
});
return Ok();
}
}

View File

@ -24,7 +24,8 @@ public class DisciplineController(IMediator mediator) : BaseController
/// <returns>Paginated list of disciplines.</returns>
[HttpGet]
[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()
{

View File

@ -23,7 +23,8 @@ public class FacultyController(IMediator mediator) : BaseController
/// <returns>Paginated list of faculties.</returns>
[HttpGet]
[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()
{

View File

@ -38,7 +38,8 @@ public class GroupController(IMediator mediator) : BaseController
/// <returns>A list of groups.</returns>
[HttpGet]
[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()
{

View File

@ -40,7 +40,7 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
/// <returns>Excel file</returns>
[HttpPost("ImportToExcel")]
[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
{
@ -48,18 +48,16 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
DisciplineIds = request.Disciplines,
GroupIds = request.Groups,
LectureHallIds = request.LectureHalls,
ProfessorIds = request.Professors
})).Schedules;
if (result.Count == 0)
return NoContent();
ProfessorIds = request.Professors,
LessonTypeIds = request.LessonType
})).Schedules.ToList();
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
using var package = new ExcelPackage();
var worksheet = package.Workbook.Worksheets.Add("Расписание");
int row = 1;
int col = 1;
var row = 1;
var col = 1;
worksheet.Cells[row, col++].Value = "День";
worksheet.Cells[row, col++].Value = "Пара";

View File

@ -0,0 +1,43 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class LessonTypeController(IMediator mediator) : BaseController
{
/// <summary>
/// Gets a paginated list of type of occupation.
/// </summary>
/// <param name="page">Page number. Start from 0.</param>
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of type of occupation.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<LessonTypeResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
{
var result = await mediator.Send(new GetTypeOfOccupationListQuery()
{
Page = page,
PageSize = pageSize
});
return Ok(result.TypeOfOccupations
.Select(f => new LessonTypeResponse()
{
Id = f.Id,
Name = f.Name
})
);
}
}

View File

@ -26,7 +26,8 @@ public class ProfessorController(IMediator mediator) : BaseController
/// <returns>A list of professors.</returns>
[HttpGet]
[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()
{

View File

@ -51,7 +51,8 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
if ((request.Groups == null || request.Groups.Length == 0) &&
(request.Disciplines == null || request.Disciplines.Length == 0) &&
(request.Professors == null || request.Professors.Length == 0) &&
(request.LectureHalls == null || request.LectureHalls.Length == 0))
(request.LectureHalls == null || request.LectureHalls.Length == 0) &&
(request.LessonType == null || request.LessonType.Length == 0))
{
throw new ControllerArgumentException("At least one of the arguments must be selected."
+ (request.IsEven.HasValue
@ -65,8 +66,9 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
DisciplineIds = request.Disciplines,
GroupIds = request.Groups,
LectureHallIds = request.LectureHalls,
ProfessorIds = request.Professors
})).Schedules;
ProfessorIds = request.Professors,
LessonTypeIds = request.LessonType
})).Schedules.ToList();
if (result.Count == 0)
NoContent();
@ -101,6 +103,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified group.</returns>
[HttpGet("GetByGroup/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -110,14 +113,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? professors = null,
[FromQuery] int[]? lectureHalls = null) =>
[FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = [id],
Professors = professors,
LectureHalls = lectureHalls
LectureHalls = lectureHalls,
LessonType = lessonType
});
/// <summary>
@ -128,6 +133,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="groups">An array of group IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified professor.</returns>
[HttpGet("GetByProfessor/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -137,14 +143,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? lectureHalls = null) =>
[FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = groups,
Professors = [id],
LectureHalls = lectureHalls
LectureHalls = lectureHalls,
LessonType = lessonType
});
/// <summary>
@ -155,6 +163,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="groups">An array of group IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified lecture hall.</returns>
[HttpGet("GetByLectureHall/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -164,14 +173,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? professors = null) =>
[FromQuery] int[]? professors = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = groups,
Professors = professors,
LectureHalls = [id]
LectureHalls = [id],
LessonType = lessonType
});
/// <summary>
@ -182,6 +193,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="groups">An array of group IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified discipline.</returns>
[HttpGet("GetByDiscipline/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -191,13 +203,15 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? professors = null,
[FromQuery] int[]? lectureHalls = null) =>
[FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = [id],
IsEven = isEven,
Groups = groups,
Professors = professors,
LectureHalls = lectureHalls
LectureHalls = lectureHalls,
LessonType = lessonType
});
}

View File

@ -28,7 +28,7 @@ public class SecurityController(IOptionsSnapshot<GeneralConfig> generalConfig) :
[HttpGet("GenerateTotpQrCode")]
[Produces("image/svg+xml")]
[MaintenanceModeIgnore]
public IActionResult GenerateTotpQrCode(
public ContentResult GenerateTotpQrCode(
[FromQuery] string totpKey,
[FromQuery] string label,
[FromQuery] string? backgroundColor = null,
@ -80,6 +80,7 @@ public class SecurityController(IOptionsSnapshot<GeneralConfig> generalConfig) :
/// The current password policy
/// </returns>
[HttpGet("PasswordPolicy")]
[MaintenanceModeIgnore]
public ActionResult<PasswordPolicy> PasswordPolicy() =>
Ok(generalConfig.Value.PasswordPolicy.ConvertToDto());
}

View File

@ -5,62 +5,65 @@
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<Version>1.0-rc6</Version>
<AssemblyVersion>1.0.2.6</AssemblyVersion>
<FileVersion>1.0.2.6</FileVersion>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion>
<AssemblyName>Mirea.Api.Endpoint</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<OutputType>Exe</OutputType>
<InvariantGlobalization>false</InvariantGlobalization>
<UserSecretsId>65cea060-88bf-4e35-9cfb-18fc996a8f05</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>.</DockerfileContext>
<SignAssembly>False</SignAssembly>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile>docs.xml</DocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
<PackageReference Include="AspNetCore.HealthChecks.System" Version="8.0.1" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.System" Version="9.0.0" />
<PackageReference Include="Cronos" Version="0.9.0" />
<PackageReference Include="EPPlus" Version="7.5.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.12.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.11">
<PackageReference Include="EPPlus" Version="7.6.1" />
<PackageReference Include="EPPlus.System.Drawing" Version="8.0.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.13" />
<PackageReference Include="Microsoft.Build.Framework" Version="17.13.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="8.0.11">
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="9.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.3.0" />
<PackageReference Include="Mirea.Tools.Schedule.WebParser" Version="1.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
<PackageReference Include="Microsoft.IdentityModel.Protocols" Version="8.6.1" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.6.1" />
<PackageReference Include="Mirea.Tools.Schedule.Parser" Version="1.2.5" />
<PackageReference Include="Mirea.Tools.Schedule.WebParser" Version="1.0.6" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.11" />
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<PackageReference Include="System.CodeDom" Version="[8.0.0, 9.0.0)" />
<PackageReference Include="System.Composition" Version="[8.0.0, 9.0.0)" />
<PackageReference Include="System.Composition.TypedParts" Version="[8.0.0, 9.0.0)" />
<PackageReference Include="System.Drawing.Common" Version="8.0.11" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.0" />
<PackageReference Include="System.Threading.Channels" Version="[8.0.0, 9.0.0)" />
<PackageReference Include="Z.EntityFramework.Extensions.EFCore" Version="8.103.6.4" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.2" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.31" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="System.CodeDom" Version="9.0.2" />
<PackageReference Include="System.Composition" Version="9.0.2" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.2" />
<PackageReference Include="System.Drawing.Common" Version="9.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.6.1" />
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="9.0.2" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="9.0.2" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.2" />
<PackageReference Include="System.Threading.Channels" Version="9.0.2" />
<PackageReference Include="Z.EntityFramework.Extensions.EFCore" Version="9.103.7.2" />
</ItemGroup>
<ItemGroup>

View File

@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@ -58,7 +60,10 @@ public class Program
builder.Host.AddCustomSerilog();
AddDatabase(builder.Services, builder.Configuration, healthCheckBuilder);
builder.Services.AddControllers();
builder.Services.AddControllers(options =>
{
options.Filters.Add(new ProducesResponseTypeAttribute(StatusCodes.Status500InternalServerError));
});
builder.Services.AddSingleton<IMaintenanceModeNotConfigureService, MaintenanceModeNotConfigureService>();
builder.Services.AddSingleton<IMaintenanceModeService, MaintenanceModeService>();

View File

@ -6,9 +6,11 @@ using Mirea.Api.DataAccess.Persistence;
using Mirea.Api.Endpoint.Common.Interfaces;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Sync.Common;
using Mirea.Tools.Schedule.Parser.Domain;
using Mirea.Tools.Schedule.WebParser;
using Mirea.Tools.Schedule.WebParser.Common.Domain;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
@ -19,7 +21,8 @@ using Group = Mirea.Api.DataAccess.Domain.Schedule.Group;
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<Discipline> _disciplines = new([.. dbContext.Disciplines]);
@ -120,7 +123,7 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
{
hall = [];
campuses = [];
for (int i = 0; i < groupInfo.Campuses.Length; i++)
for (var i = 0; i < groupInfo.Campuses.Length; i++)
{
var campus = groupInfo.Campuses[i];
campuses.Add(_campuses.GetOrCreate(
@ -151,7 +154,7 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
Name = groupInfo.Discipline
});
var lesson = _lessons.GetOrCreate(l =>
Lesson lesson = _lessons.GetOrCreate(l =>
l.IsEven == groupInfo.IsEven &&
l.DayOfWeek == groupInfo.Day &&
l.PairNumber == groupInfo.Pair &&
@ -182,9 +185,9 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
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 lectureHall = hall?.ElementAtOrDefault(i);
@ -219,33 +222,15 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
await dbContext.LessonAssociations.BulkSynchronizeAsync(_lessonAssociation.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
}
public async Task StartSync(CancellationToken cancellationToken)
private async Task Sync(Func<CancellationToken, Task<List<GroupResult>>> parseDataAsync, CancellationToken cancellationToken)
{
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod;
var startTerm = config.Value.ScheduleSettings?.StartTerm;
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));
return;
}
Stopwatch watch = new();
watch.Start();
var parser = new Parser
{
Pairs = pairPeriods
.ToDictionary(x => x.Key,
x => (x.Value.Start, x.Value.End)),
TermStart = startTerm.Value.ToDateTime(new TimeOnly(0, 0, 0))
};
try
{
logger.LogDebug("Start parsing schedule");
var data = await parser.ParseAsync(cancellationToken);
var data = await parseDataAsync(cancellationToken);
watch.Stop();
var parsingTime = watch.ElapsedMilliseconds;
@ -279,11 +264,255 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
maintenanceMode.DisableMaintenanceMode();
throw;
}
finally
{
maintenanceMode.DisableMaintenanceMode();
}
}
public async Task StartSync(CancellationToken cancellationToken)
{
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod
.ToDictionary(x => x.Key, x => (x.Value.Start, x.Value.End));
var startTerm = config.Value.ScheduleSettings?.StartTerm;
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));
return;
}
var parser = new Parser
{
Pairs = pairPeriods
.ToDictionary(x => x.Key,
x => (x.Value.Start, x.Value.End)),
TermStart = startTerm.Value.ToDateTime(new TimeOnly(0, 0, 0))
};
try
{
await Sync(parser.ParseAsync, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
throw;
}
finally
{
maintenanceMode.DisableMaintenanceMode();
}
}
public async Task StartSync(List<(string File, string Campus)> files, CancellationToken cancellationToken)
{
await Task.Yield();
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod
.ToDictionary(x => x.Key, x => (x.Value.Start, x.Value.End));
if (pairPeriods == null)
{
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} variable is not initialized.",
nameof(pairPeriods));
return;
}
try
{
Task<List<GroupResult>> ParseTask(CancellationToken ctx)
{
var mappedData = new ConcurrentBag<GroupResult>();
ParallelOptions options = new() { CancellationToken = ctx, MaxDegreeOfParallelism = Environment.ProcessorCount };
Parallel.ForEach(files, options, (file) =>
{
var parser = new Tools.Schedule.Parser.Parser();
var result = ConvertToGroupResults(parser.Parse(file.File, pairPeriods), file.Campus);
foreach (var item in result) mappedData.Add(item);
});
return Task.FromResult(mappedData.ToList());
}
await Sync(ParseTask, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
throw;
}
finally
{
maintenanceMode.DisableMaintenanceMode();
}
}
private static List<GroupResult> ConvertToGroupResults(IEnumerable<GroupInfo> groups, string campusDefault, CancellationToken cancellationToken = default)
{
var result = new List<GroupResult>();
foreach (var group in groups)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var day in group.Days)
{
foreach (var pair in day.Lessons)
{
foreach (var lesson in pair.Value)
{
if (string.IsNullOrWhiteSpace(lesson.TypeOfOccupation))
continue;
var (weeks, isExclude) = ParseWeeks(lesson.Discipline);
var (lectureHalls, campuses) = ParseLectureHalls(lesson.LectureHall, campusDefault);
var groupResult = new GroupResult
{
Day = day.DayOfWeek,
Pair = pair.Key,
IsEven = lesson.IsEven,
Group = group.GroupName,
Discipline = NormalizeDiscipline(lesson.Discipline),
Professor = ParseProfessors(lesson.Professor),
TypeOfOccupation = lesson.TypeOfOccupation,
LectureHalls = lectureHalls,
Campuses = campuses,
SpecialWeek = weeks,
IsExclude = isExclude
};
result.Add(groupResult);
}
}
}
}
return result;
}
private static string[]? ParseProfessors(string? input)
{
if (string.IsNullOrWhiteSpace(input)) return null;
var normalized = Regex.Replace(input
.Replace("\n", " ")
.Replace(",", " "),
@"\s+", " ").Trim();
return ProfessorFullName().Matches(normalized)
.Select(m => $"{m.Groups["surname"].Value} {m.Groups["initials"].Value}".Trim())
.Where(x => !string.IsNullOrEmpty(x))
.ToArray();
}
private static (int[]? weeks, bool? isExclude) ParseWeeks(string discipline)
{
var match = ParseSpecificWeeks().Match(discipline);
if (!match.Success) return (null, null);
var numbers = new List<int>();
var ranges = match.Groups[2].Value.Split(',');
foreach (var range in ranges)
{
if (range.Contains('-'))
{
var parts = range.Split('-');
if (int.TryParse(parts[0], out var start) &&
int.TryParse(parts[1], out var end))
{
numbers.AddRange(Enumerable.Range(start, end - start + 1));
}
}
else
if (int.TryParse(range, out var num)) numbers.Add(num);
}
return (
weeks: numbers.Distinct().OrderBy(x => x).ToArray(),
isExclude: match.Groups[1].Success
);
}
private static string NormalizeDiscipline(string input)
{
var normalized = Regex.Replace(input
.Replace("\n", " ")
.Replace("\r", " "),
@"\s{2,}", " ");
normalized = Regex.Replace(normalized,
@"(\S+)\s(\S{3,})",
"$1 $2");
normalized = ParseSpecificWeeks().Replace(normalized, "");
return normalized.Trim();
}
private static (string[]? lectureHalls, string[]? campuses) ParseLectureHalls(string? input, string defaultCampus)
{
if (string.IsNullOrWhiteSpace(input))
return (null, null);
var matches = ParseLectureCampus().Matches(input);
var lectureHalls = new List<string>();
var campuses = new List<string>();
foreach (Match match in matches)
{
if (match.Groups["lectureWithCampus"].Success)
{
var raw = match.Value.Split('(');
var campus = raw.LastOrDefault()?.Trim(')').Trim();
var lecture = raw.FirstOrDefault()?.Trim();
if (string.IsNullOrEmpty(campus) || string.IsNullOrEmpty(lecture))
continue;
campuses.Add(campus);
lectureHalls.Add(lecture);
}
else if (match.Groups["lecture"].Success)
{
var lecture = match.Value.Trim();
if (string.IsNullOrEmpty(lecture))
continue;
campuses.Add(defaultCampus);
lectureHalls.Add(lecture);
}
}
return (
lectureHalls: lectureHalls.ToArray(),
campuses: campuses.ToArray()
);
}
[GeneratedRegex(@"\w{4}-\d{2}-\d{2}(?=\s?\d?\s?[Пп]/?[Гг]\s?\d?)?")]
private static partial Regex OnlyGroupName();
[GeneratedRegex(@"(?<surname>[А-ЯЁ][а-яё]+(-[А-ЯЁ][а-яё]+)?)\s*(?<initials>[А-ЯЁ]\.[А-ЯЁ]?\.?)?", RegexOptions.IgnorePatternWhitespace)]
private static partial Regex ProfessorFullName();
[GeneratedRegex(@"([Кк]р\.?)?\s*((\d+-\d+|\d+)(,\s*\d+(-\d+)?)*)\s*[Нн]\.?", RegexOptions.IgnoreCase, "ru-RU")]
private static partial Regex ParseSpecificWeeks();
[GeneratedRegex(@"(?<lectureWithCampus>[^,.\n]+\s?\([А-Яа-яA-Za-z]+-?\d+\))|(?<lecture>[^,.\n]+)")]
private static partial Regex ParseLectureCampus();
}

View File

@ -84,14 +84,13 @@ To set up the `redirect URL` when registering and logging in using OAuth 2, use
"{schema}://{domain}{portString}{ACTUAL_SUB_PATH}/api/v1/Auth/OAuth2"
```
** Where:**
**Where:**
- `{schema}` is the protocol you are using (`http` or `https').
- `{domain}` is your domain (for example, `mydomain.com ` or IP address).
- `{portString}` is a port string that is only needed if your application is running on a nonstandard port (for
- `{schema}` is the protocol you are using (`http` or `https`).
- `{domain}` is your domain (for example, `mydomain.com` or IP address).
- `{portString}` is a port string that is only needed if your application is running on a non-standard port (for
example, `:8080`). If you use standard ports (`80` for `http` and `443` for `https`), this parameter can be omitted.
- `{ACTUAL_SUB_PATH}` is the path to your API that you specify in the settings. If it ends with `/api', then don't add `
/api` at the end of the URL.
- `{ACTUAL_SUB_PATH}` is the path to your API that you specify in the settings. If it ends with `/api`, then don't add `/api` at the end of the URL.
**Examples:**

View File

@ -1,4 +1,6 @@
namespace Mirea.Api.Security.Common.Domain.Caching;
using Mirea.Api.Security.Common.Model;
namespace Mirea.Api.Security.Common.Domain.Caching;
internal class FirstAuthToken
{

View File

@ -0,0 +1,12 @@
namespace Mirea.Api.Security.Common.Domain.Caching;
internal class OAuthUserExtension
{
public string? Message { get; set; }
public bool IsSuccess { get; set; }
public required OAuthProvider? Provider { get; set; }
public string? UserAgent { get; set; } = null;
public string? Ip { get; set; } = null;
public string? Fingerprint { get; set; } = null;
public OAuthUser? User { get; set; }
}

View File

@ -0,0 +1,7 @@
namespace Mirea.Api.Security.Common.Domain;
internal class OAuthPayload
{
public required OAuthProvider Provider { get; set; }
public required string Callback { get; set; }
}

View File

@ -1,8 +1,8 @@
using System;
namespace Mirea.Api.Security.Common.Domain.OAuth2;
namespace Mirea.Api.Security.Common.Domain;
internal struct OAuthProviderUrisData
internal readonly struct OAuthProviderUrisData
{
public string RedirectUrl { get; init; }
public string TokenUrl { get; init; }

View File

@ -7,7 +7,7 @@ namespace Mirea.Api.Security.Common.Domain;
internal class RequestContextInfo
{
public RequestContextInfo(HttpContext context, CookieOptionsParameters cookieOptions)
public RequestContextInfo(HttpContext context, Model.CookieOptions cookieOptions)
{
var ipEntity = context.Connection.RemoteIpAddress;
@ -28,7 +28,7 @@ internal class RequestContextInfo
UserAgent = userAgent;
Fingerprint = fingerprint;
Ip = ip;
RefreshToken = context.Request.Cookies["refresh_token"] ?? string.Empty;
RefreshToken = context.Request.Cookies[CookieNames.RefreshToken] ?? string.Empty;
}
public string UserAgent { get; private set; }

View File

@ -13,4 +13,4 @@ public interface ICacheService
Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default);
Task RemoveAsync(string key, CancellationToken cancellationToken = default);
}
}

View File

@ -1,16 +1,16 @@
using Microsoft.AspNetCore.Http;
using System;
namespace Mirea.Api.Security.Common.Domain;
namespace Mirea.Api.Security.Common.Model;
public class CookieOptionsParameters
public class CookieOptions
{
public required string Domain { get; set; }
public required string Path { get; set; }
internal void SetCookie(HttpContext context, string name, string value, DateTimeOffset? expires = null)
{
var cookieOptions = new CookieOptions
var cookieOptions = new Microsoft.AspNetCore.Http.CookieOptions
{
Expires = expires,
Path = Path,
@ -26,4 +26,4 @@ public class CookieOptionsParameters
internal void DropCookie(HttpContext context, string name) =>
SetCookie(context, name, "", DateTimeOffset.MinValue);
}
}

View File

@ -1,4 +1,4 @@
namespace Mirea.Api.Security.Common.Domain;
namespace Mirea.Api.Security.Common.Model;
public class PasswordPolicy(
int minimumLength = 8,

View File

@ -1,4 +1,4 @@
namespace Mirea.Api.Security.Common.Domain;
namespace Mirea.Api.Security.Common.Model;
public enum TwoFactorAuthenticator
{

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using Mirea.Api.Security.Common.Domain;
using System.Collections.Generic;
namespace Mirea.Api.Security.Common.Domain;
namespace Mirea.Api.Security.Common.Model;
public class User
{

View File

@ -1,8 +1,8 @@
using System.Text.Json.Serialization;
namespace Mirea.Api.Security.Common.Domain.OAuth2;
namespace Mirea.Api.Security.Common.OAuth2;
public class OAuthTokenResponse
internal class OAuthTokenResponse
{
[JsonPropertyName("access_token")]
public required string AccessToken { get; set; }

View File

@ -1,7 +1,8 @@
using Mirea.Api.Security.Common.Interfaces;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Interfaces;
using System.Text.Json.Serialization;
namespace Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
namespace Mirea.Api.Security.Common.OAuth2.UserInfo;
internal class GoogleUserInfo : IUserInfo
{

View File

@ -1,7 +1,8 @@
using Mirea.Api.Security.Common.Interfaces;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Interfaces;
using System.Text.Json.Serialization;
namespace Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
namespace Mirea.Api.Security.Common.OAuth2.UserInfo;
internal class MailRuUserInfo : IUserInfo
{

View File

@ -1,7 +1,8 @@
using Mirea.Api.Security.Common.Interfaces;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Interfaces;
using System.Text.Json.Serialization;
namespace Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
namespace Mirea.Api.Security.Common.OAuth2.UserInfo;
internal class YandexUserInfo : IUserInfo
{

View File

@ -0,0 +1,11 @@
using System;
namespace Mirea.Api.Security.Common.ViewModel;
public class LoginOAuth
{
public bool Success { get; set; }
public required string Token { get; set; }
public Uri? Callback { get; set; }
public string? ErrorMessage { get; set; }
}

View File

@ -6,11 +6,29 @@ using Mirea.Api.Security.Common.Interfaces;
using Mirea.Api.Security.Services;
using System;
using System.Collections.Generic;
using System.Text;
namespace Mirea.Api.Security;
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)
{
var saltSize = int.Parse(configuration["SECURITY_SALT_SIZE"]!);
@ -61,7 +79,13 @@ public static class DependencyInjection
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;
}

View File

@ -5,9 +5,9 @@
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<Version>1.1.1</Version>
<AssemblyVersion>1.1.3.1</AssemblyVersion>
<FileVersion>1.1.3.1</FileVersion>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion>
<AssemblyName>Mirea.Api.Security</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<OutputType>Library</OutputType>
@ -15,8 +15,8 @@
<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="[8.0.0, 9.0.0)" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.2" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="System.Memory" Version="4.6.0" />
</ItemGroup>

View File

@ -4,15 +4,18 @@ using Mirea.Api.Security.Common;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Domain.Caching;
using Mirea.Api.Security.Common.Interfaces;
using Mirea.Api.Security.Common.Model;
using System;
using System.Security;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using CookieOptions = Mirea.Api.Security.Common.Model.CookieOptions;
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 LifetimeFirstAuth { private get; init; }
@ -24,15 +27,16 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
private static string GetAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token";
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 cancellationToken) =>
cache.SetAsync(
GetAuthCacheKey(data.Fingerprint),
JsonSerializer.SerializeToUtf8Bytes(data),
slidingExpiration: Lifetime,
cancellationToken: cancellation);
cancellationToken: cancellationToken);
private Task CreateFirstAuthTokenToCache(User data, RequestContextInfo requestContext, CancellationToken cancellation) =>
private Task StoreFirstAuthTokenInCache(User data, RequestContextInfo requestContext, CancellationToken cancellationToken) =>
cache.SetAsync(
GetFirstAuthCacheKey(requestContext.Fingerprint),
JsonSerializer.SerializeToUtf8Bytes(new FirstAuthToken(requestContext)
@ -42,47 +46,58 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
TwoFactorAuthenticator = data.TwoFactorAuthenticator
}),
slidingExpiration: LifetimeFirstAuth,
cancellationToken: cancellation);
cancellationToken: cancellationToken);
private Task RevokeAccessToken(string token) =>
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
private async Task VerifyUserOrThrowError(RequestContextInfo requestContext, User user, string password, string username,
CancellationToken cancellation = default)
private async Task RecordFailedLoginAttempt(string fingerprint, string userId, CancellationToken cancellationToken)
{
if ((user.Email.Equals(username, StringComparison.OrdinalIgnoreCase) || user.Username.Equals(username, StringComparison.OrdinalIgnoreCase)) &&
passwordService.VerifyPassword(password, user.Salt, user.PasswordHash))
return;
var failedLoginAttemptsCount = await cache.GetAsync<int?>(GetAttemptFailedCountKey(fingerprint), cancellationToken) ?? 1;
var failedLoginCacheExpiration = TimeSpan.FromHours(1);
var failedLoginCacheName = $"{requestContext.Fingerprint}_login_failed";
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)
if (failedLoginAttemptsCount > 5)
{
logger.LogWarning(
"Multiple unsuccessful login attempts for user ID {UserId} from IP {UserIp}. Attempt count: {AttemptNumber}.",
user.Id,
requestContext.Ip,
countFailedLogin);
"Multiple unsuccessful login attempts for user ID {UserId}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
userId,
fingerprint,
failedLoginAttemptsCount);
throw new SecurityException("Too many unsuccessful login attempts. Please try again later.");
}
logger.LogInformation(
"Login attempt failed for user ID {UserId}. IP: {UserIp}, User-Agent: {UserAgent}, Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
user.Id,
requestContext.Ip,
requestContext.UserAgent,
requestContext.Fingerprint,
countFailedLogin);
"Login attempt failed for user ID {UserId}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
userId,
fingerprint,
failedLoginAttemptsCount);
await cache.SetAsync(GetAttemptFailedCountKey(fingerprint), failedLoginAttemptsCount + 1,
slidingExpiration: failedLoginCacheExpiration, cancellationToken: cancellationToken);
}
private Task ResetFailedLoginAttempts(string fingerprint, CancellationToken cancellationToken) =>
cache.RemoveAsync(GetAttemptFailedCountKey(fingerprint), cancellationToken);
private async Task VerifyUserOrThrowError(RequestContextInfo requestContext, User user, string password, string username,
CancellationToken cancellationToken = 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, cancellationToken);
return;
}
await RecordFailedLoginAttempt(requestContext.Fingerprint, user.Id, cancellationToken);
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(CookieOptions cookieOptions, HttpContext context,
RequestContextInfo requestContext, string userId, CancellationToken cancellationToken = default)
{
var refreshToken = GenerateRefreshToken();
var (token, expireIn) = GenerateAccessToken(userId);
@ -95,38 +110,22 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
AccessToken = token
};
await SetAuthTokenDataToCache(authToken, cancellation);
await StoreAuthTokenInCache(authToken, cancellationToken);
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
logger.LogInformation(
"Login successful for user ID {UserId}. IP: {UserIp}, User-Agent: {UserAgent}, Fingerprint: {Fingerprint}.",
"Login successful for user ID {UserId}. Fingerprint: {Fingerprint}.",
authToken.UserId,
authToken.Ip,
authToken.UserAgent,
authToken.Fingerprint);
}
public async Task<TwoFactorAuthenticator> LoginOAuthAsync(CookieOptionsParameters cookieOptions, HttpContext context, User user, OAuthProvider provider, CancellationToken cancellation = default)
public async Task<bool> LoginAsync(CookieOptions cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code,
CancellationToken cancellationToken = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
{
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation);
return TwoFactorAuthenticator.None;
}
await CreateFirstAuthTokenToCache(user, requestContext, cancellation);
return user.TwoFactorAuthenticator;
}
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code, CancellationToken cancellation = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellation);
var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellationToken);
if (firstTokenAuth == null || authenticator != firstTokenAuth.TwoFactorAuthenticator)
throw new SecurityException("Session expired. Please log in again.");
@ -136,64 +135,135 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
case TwoFactorAuthenticator.Totp:
{
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.");
}
var totp = new TotpService(firstTokenAuth.Secret);
if (!totp.VerifyToken(code))
{
await RecordFailedLoginAttempt(requestContext.Fingerprint, firstTokenAuth.UserId, cancellationToken);
throw new SecurityException("Invalid verification code. Please try again.");
}
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellationToken);
}
break;
default:
throw new InvalidOperationException("Unsupported authorization method.");
}
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellation);
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellationToken);
return true;
}
public async Task<TwoFactorAuthenticator> LoginAsync(CookieOptionsParameters cookieOptions, User user, HttpContext context, string password, string username, CancellationToken cancellation = default)
private async Task<TwoFactorAuthenticator> LoginAsync(CookieOptions cookieOptions,
HttpContext context,
User user,
CancellationToken cancellationToken = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
await VerifyUserOrThrowError(requestContext, user, password, username, cancellation);
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
{
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation);
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellationToken);
return TwoFactorAuthenticator.None;
}
await CreateFirstAuthTokenToCache(user, requestContext, cancellation);
await StoreFirstAuthTokenInCache(user, requestContext, cancellationToken);
return user.TwoFactorAuthenticator;
}
public async Task RefreshTokenAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
public Task<TwoFactorAuthenticator> LoginOAuthAsync(CookieOptions cookieOptions,
HttpContext context,
User user,
OAuthUser oAuthUser,
OAuthProvider provider,
CancellationToken cancellation = default)
{
if (user.OAuthProviders == null || !user.OAuthProviders.TryGetValue(provider, out var value))
throw new SecurityException($"This provider '{Enum.GetName(provider)}' is not linked to the account.");
if (value.Id != oAuthUser.Id)
throw new SecurityException("This account was not linked");
return LoginAsync(cookieOptions, context, user, cancellation);
}
public async Task<TwoFactorAuthenticator> LoginAsync(CookieOptions cookieOptions,
HttpContext context,
User user,
string password,
string username,
CancellationToken cancellationToken = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation)
?? throw new SecurityException("The session time has expired");
username = username.Trim();
await VerifyUserOrThrowError(requestContext, user, password, username, cancellationToken);
return await LoginAsync(cookieOptions, context, user, cancellationToken);
}
public async Task RefreshTokenAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellationToken = default)
{
const string defaultMessageError = "The session time has expired";
var requestContext = new RequestContextInfo(context, cookieOptions);
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellationToken) ??
throw new SecurityException(defaultMessageError);
if (authToken.RefreshToken != requestContext.RefreshToken ||
authToken.UserAgent != requestContext.UserAgent &&
authToken.Ip != requestContext.Ip)
{
await RevokeAccessToken(authToken.AccessToken);
await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellationToken);
cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
logger.LogWarning("Token validation failed for user ID {UserId}. IP: {UserIp}, User-Agent: {UserAgent}, Fingerprint: {Fingerprint}. Reason: {Reason}.",
logger.LogWarning("Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. " +
"RefreshToken: {ExpectedRefreshToken} -> {RefreshToken}, " +
"UserAgent: {ExpectedUserAgent} -> {ProvidedUserAgent}, " +
"Ip: {ExpectedUserIp} -> {ProvidedIp}",
authToken.UserId,
authToken.Ip,
authToken.UserAgent,
authToken.Fingerprint,
authToken.RefreshToken != requestContext.RefreshToken ?
$"Cached refresh token '{authToken.RefreshToken}' does not match the provided refresh token '{requestContext.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.RefreshToken,
requestContext.RefreshToken,
authToken.UserAgent,
requestContext.UserAgent,
authToken.Ip,
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);
@ -204,24 +274,24 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
authToken.AccessToken = token;
authToken.RefreshToken = newRefreshToken;
await SetAuthTokenDataToCache(authToken, cancellation);
await StoreAuthTokenInCache(authToken, cancellationToken);
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
}
public async Task LogoutAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
public async Task LogoutAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellationToken = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellationToken);
if (authTokenStruct == null)
return;
await RevokeAccessToken(authTokenStruct.AccessToken);
await cache.RemoveAsync(requestContext.Fingerprint, cancellation);
await cache.RemoveAsync(requestContext.Fingerprint, cancellationToken);
}
}

View File

@ -12,7 +12,7 @@ public static class GeneratorKey
var random = new Random();
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
string charsForGenerate = excludes?
var charsForGenerate = excludes?
.Aggregate(chars, (current, ex) => current.Replace(ex.ToString(), string.Empty)) ?? chars;
if (!string.IsNullOrEmpty(includes))

View File

@ -1,25 +1,31 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Domain.OAuth2;
using Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
using Mirea.Api.Security.Common.Domain.Caching;
using Mirea.Api.Security.Common.Interfaces;
using Mirea.Api.Security.Common.OAuth2;
using Mirea.Api.Security.Common.OAuth2.UserInfo;
using Mirea.Api.Security.Common.ViewModel;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using CookieOptions = Mirea.Api.Security.Common.Model.CookieOptions;
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()
{
[OAuthProvider.Google] = new OAuthProviderUrisData
@ -51,7 +57,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 cancellationToken)
{
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
@ -68,8 +75,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MireaSchedule/1.0 (Winsomnia)");
var response = await httpClient.SendAsync(tokenRequest, cancellation);
var data = await response.Content.ReadAsStringAsync(cancellation);
var response = await httpClient.SendAsync(tokenRequest, cancellationToken);
var data = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(data);
@ -77,7 +84,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
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 cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
@ -89,8 +97,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MireaSchedule/1.0 (Winsomnia)");
var response = await httpClient.SendAsync(request, cancellation);
var data = await response.Content.ReadAsStringAsync(cancellation);
var response = await httpClient.SendAsync(request, cancellationToken);
var data = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(data);
@ -99,81 +107,318 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
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(
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 providerData = providers[provider];
var data = JsonSerializer.Serialize(payload);
var redirectUrl = $"?client_id={providerData.ClientId}" +
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);
}
}
private Task StoreOAuthUserInCache(string key, OAuthUserExtension data, CancellationToken cancellationToken) =>
cache.SetAsync(
key,
JsonSerializer.SerializeToUtf8Bytes(data),
absoluteExpirationRelativeToNow: TimeSpan.FromMinutes(15),
cancellationToken: cancellationToken);
public Uri GetProviderRedirect(CookieOptions cookieOptions, HttpContext context, string redirectUri,
OAuthProvider provider, Uri callback)
{
var (clientId, _) = providers[provider];
var requestInfo = new RequestContextInfo(context, cookieOptions);
var payload = EncryptPayload(new OAuthPayload()
{
Provider = provider,
Callback = callback.AbsoluteUri
});
var checksum = GetHmacString(requestInfo);
var redirectUrl = $"?client_id={clientId}" +
"&response_type=code" +
$"&redirect_uri={redirectUri}" +
$"&scope={ProviderData[provider].Scope}" +
$"&state={GetHmacString(new RequestContextInfo(context, cookieOptions), secretKey)}_{Enum.GetName(provider)}";
$"&state={Uri.EscapeDataString(payload + "_" + checksum)}" +
"&prompt=select_account" +
"&force_confirm=true";
logger.LogInformation("Redirecting user Fingerprint: {Fingerprint} to OAuth provider {Provider} with state: {State}",
requestInfo.Fingerprint,
provider,
checksum);
return new Uri(ProviderData[provider].RedirectUrl + redirectUrl);
}
public (OAuthProvider Provider, Uri Redirect)[] GetAvailableProviders(HttpContext context, string redirectUri)
{
return providers.Select(x => (x.Key, new Uri(redirectUri.TrimEnd('/') + "/?provider=" + (int)x.Key)))
.ToArray();
}
public (OAuthProvider Provider, Uri Redirect)[] GetAvailableProviders(string redirectUri) =>
[.. 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<LoginOAuth> LoginOAuth(CookieOptions cookieOptions, HttpContext context,
string redirectUrl, string code, string state, CancellationToken cancellationToken = default)
{
var partsState = state.Split('_');
if (!Enum.TryParse<OAuthProvider>(partsState.Last(), true, out var provider) ||
!providers.TryGetValue(provider, out var providerInfo) ||
!ProviderData.TryGetValue(provider, out var currentProviderStruct))
var result = new LoginOAuth()
{
logger.LogWarning("Failed to parse OAuth provider from state: {State}", state);
throw new InvalidOperationException("Invalid authorization request.");
Token = GeneratorKey.GenerateBase64(32)
};
var parts = state.Split('_');
if (parts.Length != 2)
{
result.ErrorMessage = "The request data is invalid or malformed.";
await StoreOAuthUserInCache(result.Token, new OAuthUserExtension()
{
Message = result.ErrorMessage,
Provider = null
}, cancellationToken);
return result;
}
var secretStateData = string.Join("_", partsState.SkipLast(1));
var secretData = GetHmacString(new RequestContextInfo(context, cookieOptions), secretKey);
var payload = DecryptPayload(parts[0]);
var checksum = parts[1];
if (secretData != secretStateData)
var cacheData = new OAuthUserExtension()
{
logger.LogWarning("Fingerprint mismatch. Possible CSRF attack detected.");
throw new SecurityException("Suspicious activity detected. Please try again.");
Provider = payload.Provider
};
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.";
cacheData.Message = result.ErrorMessage;
await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result;
}
OAuthTokenResponse? accessToken = null;
var requestInfo = new RequestContextInfo(context, cookieOptions);
var checksumRequest = GetHmacString(requestInfo);
result.ErrorMessage = "Authorization failed. Please try again later.";
cacheData.Message = result.ErrorMessage;
if (checksumRequest != checksum)
{
logger.LogWarning(
"Fingerprint mismatch. Possible CSRF attack detected. Fingerprint: {Fingerprint}, State: {State}, ExpectedState: {ExpectedState}",
requestInfo.Fingerprint,
checksumRequest,
checksum
);
await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result;
}
OAuthTokenResponse? accessToken;
try
{
accessToken = await ExchangeCodeForTokensAsync(currentProviderStruct.TokenUrl, redirectUrl, code, providerInfo.ClientId, providerInfo.Secret, cancellation);
accessToken = await ExchangeCodeForTokensAsync(currentProviderStruct.TokenUrl, redirectUrl, code, providerInfo.ClientId,
providerInfo.Secret, cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to exchange authorization code for tokens with provider {Provider}", provider);
logger.LogWarning(ex, "Failed to exchange code for access token with provider {Provider}. State: {State}",
payload.Provider,
checksum);
await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result;
}
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
{
result = await GetUserProfileAsync(currentProviderStruct.UserInfoUrl, currentProviderStruct.AuthHeader, accessToken.AccessToken, provider, cancellation);
user = await GetUserProfileAsync(currentProviderStruct.UserInfoUrl, currentProviderStruct.AuthHeader, accessToken.AccessToken,
payload.Provider, cancellationToken);
}
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);
await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result;
}
if (result == null)
throw new SecurityException("Unable to retrieve user information. Please check the details and try again.");
if (user == null)
return result;
return (provider, result);
result.ErrorMessage = null;
result.Success = true;
await StoreOAuthUserInCache(result.Token, new OAuthUserExtension
{
IsSuccess = true,
User = user,
Provider = payload.Provider
}, cancellationToken);
return result;
}
public async Task<(OAuthUser? User, string? Message, bool IsSuccess, OAuthProvider? Provider)>
GetOAuthUser(CookieOptions cookieOptions, HttpContext context, string token, CancellationToken cancellationToken = default)
{
var requestInfo = new RequestContextInfo(context, cookieOptions);
var result = await cache.GetAsync<OAuthUserExtension>(token, cancellationToken);
var tokenFailedKey = $"{requestInfo.Fingerprint}_oauth_token_failed";
if (result == null)
{
var failedTokenAttemptsCount = await cache.GetAsync<int?>(
tokenFailedKey,
cancellationToken) ?? 1;
var failedTokenCacheExpiration = TimeSpan.FromHours(1);
if (failedTokenAttemptsCount > 5)
{
logger.LogWarning(
"Multiple unsuccessful token attempts detected. Token {Token}, Fingerprint: {Fingerprint}. Attempt count: {AttemptCount}.",
token,
requestInfo.Fingerprint,
failedTokenAttemptsCount);
return (null, "Too many unsuccessful token attempts. Please try again later.", false, null);
}
logger.LogInformation(
"Cache data not found or expired for token: {Token}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
token,
requestInfo.Fingerprint,
failedTokenAttemptsCount);
await cache.SetAsync(tokenFailedKey,
failedTokenAttemptsCount + 1,
slidingExpiration: failedTokenCacheExpiration,
cancellationToken: cancellationToken);
return (null, "Invalid or expired token.", false, null);
}
const string log = "Cache data retrieved for token: {Token}. Fingerprint: {Fingerprint}.";
if (result.User != null)
logger.LogInformation(log + " Provider: {Provider}. UserId: {UserId}.",
token,
requestInfo.Fingerprint,
result.User.Id,
result.Provider);
else if (result.Provider != null)
logger.LogInformation(log + " Provider: {Provider}.",
token,
requestInfo.Fingerprint,
result.Provider);
else
logger.LogInformation(log, token, requestInfo.Fingerprint);
if ((!string.IsNullOrEmpty(result.Fingerprint) &&
result.Fingerprint != requestInfo.Fingerprint) ||
(!string.IsNullOrEmpty(result.UserAgent) &&
result.UserAgent != requestInfo.UserAgent &&
!string.IsNullOrEmpty(result.Ip)) &&
result.Ip != requestInfo.Ip)
{
logger.LogWarning(
"Potential token compromise detected. " +
"Token {Token} has been used from different location. " +
"Fingerprint: {ExpectedFingerprint} -> {ProvidedFingerprint}, " +
"UserAgent: {ExpectedUserAgent} -> {ProvidedUserAgent}, " +
"Ip: {ExpectedUserIp} -> {ProvidedIp}",
token,
result.Fingerprint,
requestInfo.Fingerprint,
result.UserAgent,
requestInfo.UserAgent,
result.Ip,
requestInfo.Ip);
await cache.RemoveAsync(token, cancellationToken);
return (null, "Invalid or expired token.", false, null);
}
await cache.RemoveAsync(tokenFailedKey, cancellationToken);
result.Ip = requestInfo.Ip;
result.UserAgent = requestInfo.UserAgent;
result.Fingerprint = requestInfo.Fingerprint;
await StoreOAuthUserInCache(token, result, cancellationToken);
return (result.User, result.Message, result.IsSuccess, result.Provider);
}
}

View File

@ -34,8 +34,8 @@ public class PasswordHashService
if (a.Length != b.Length)
return false;
int result = 0;
for (int i = 0; i < a.Length; i++)
var result = 0;
for (var i = 0; i < a.Length; i++)
result |= a[i] ^ b[i];
return result == 0;
}

View File

@ -1,4 +1,4 @@
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Model;
using System.Linq;
using System.Security;

View File

@ -5,9 +5,9 @@
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<Version>1.0.3</Version>
<AssemblyVersion>1.0.3.3</AssemblyVersion>
<FileVersion>1.0.3.3</FileVersion>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion>
<AssemblyName>Mirea.Api.DataAccess.Application</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
</PropertyGroup>
@ -16,7 +16,7 @@
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.2" />
</ItemGroup>
<ItemGroup>

View File

@ -10,5 +10,5 @@ public class CampusBasicInfoVm
/// <summary>
/// The list of campus basic information.
/// </summary>
public IList<CampusBasicInfoDto> Campuses { get; set; } = new List<CampusBasicInfoDto>();
public IEnumerable<CampusBasicInfoDto> Campuses { get; set; } = [];
}

View File

@ -11,7 +11,8 @@ public class GetCampusDetailsQueryHandler(ICampusDbContext dbContext) : IRequest
{
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()
{

View File

@ -11,7 +11,8 @@ public class GetDisciplineInfoQueryHandler(IDisciplineDbContext dbContext) : IRe
{
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()
{

View File

@ -10,5 +10,5 @@ public class DisciplineListVm
/// <summary>
/// The list of disciplines.
/// </summary>
public IList<DisciplineLookupDto> Disciplines { get; set; } = new List<DisciplineLookupDto>();
public IEnumerable<DisciplineLookupDto> Disciplines { get; set; } = [];
}

View File

@ -10,5 +10,5 @@ public class FacultyListVm
/// <summary>
/// The list of faculties.
/// </summary>
public IList<FacultyLookupDto> Faculties { get; set; } = new List<FacultyLookupDto>();
public IEnumerable<FacultyLookupDto> Faculties { get; set; } = [];
}

View File

@ -14,9 +14,4 @@ public class FacultyLookupDto
/// The name of the faculty.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// ID indicating the faculty's affiliation to the campus.
/// </summary>
public int? CampusId { get; set; }
}

View File

@ -10,5 +10,5 @@ public class GroupListVm
/// <summary>
/// The list of groups.
/// </summary>
public IList<GroupLookupDto> Groups { get; set; } = new List<GroupLookupDto>();
public IEnumerable<GroupLookupDto> Groups { get; set; } = [];
}

View File

@ -10,5 +10,5 @@ public class LectureHallListVm
/// <summary>
/// The list of lecture hall.
/// </summary>
public IList<LectureHallLookupDto> LectureHalls { get; set; } = new List<LectureHallLookupDto>();
public IEnumerable<LectureHallLookupDto> LectureHalls { get; set; } = [];
}

View File

@ -11,7 +11,8 @@ public class GetProfessorInfoQueryHandler(IProfessorDbContext dbContext) : IRequ
{
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()
{

View File

@ -27,7 +27,7 @@ public class GetProfessorInfoSearchQueryHandler(IProfessorDbContext dbContext) :
Id = x.Id,
Name = x.Name,
AltName = x.AltName
}).ToList()
})
};
}
}

View File

@ -11,5 +11,5 @@ public class ProfessorInfoListVm
/// <summary>
/// List of <see cref="ProfessorInfoVm"/>
/// </summary>
public required IList<ProfessorInfoVm> Details { get; set; }
public IEnumerable<ProfessorInfoVm> Details { get; set; } = [];
}

Some files were not shown because too many files have changed in this diff Show More