diff --git a/ApiDto/Common/CacheType.cs b/ApiDto/Common/CacheType.cs new file mode 100644 index 0000000..d8a719d --- /dev/null +++ b/ApiDto/Common/CacheType.cs @@ -0,0 +1,17 @@ +namespace Mirea.Api.Dto.Common; + +/// +/// Specifies the types of caching mechanisms available. +/// +public enum CacheType +{ + /// + /// Memcached caching type. + /// + Memcached, + + /// + /// Redis caching type. + /// + Redis +} \ No newline at end of file diff --git a/ApiDto/Common/DatabaseType.cs b/ApiDto/Common/DatabaseType.cs new file mode 100644 index 0000000..6e413b2 --- /dev/null +++ b/ApiDto/Common/DatabaseType.cs @@ -0,0 +1,22 @@ +namespace Mirea.Api.Dto.Common; + +/// +/// Specifies the types of databases supported. +/// +public enum DatabaseType +{ + /// + /// MySQL database type. + /// + Mysql, + + /// + /// SQLite database type. + /// + Sqlite, + + /// + /// PostgreSQL database type. + /// + PostgresSql +} \ No newline at end of file diff --git a/ApiDto/Requests/CreateUserRequest.cs b/ApiDto/Requests/CreateUserRequest.cs index 3bcf70c..e1b3a4c 100644 --- a/ApiDto/Requests/CreateUserRequest.cs +++ b/ApiDto/Requests/CreateUserRequest.cs @@ -10,27 +10,18 @@ public class CreateUserRequest /// /// Gets or sets the email address of the user. /// - /// - /// The email address is a required field. - /// [Required] public required string Email { get; set; } /// /// Gets or sets the username of the user. /// - /// - /// The username is a required field. - /// [Required] public required string Username { get; set; } /// /// Gets or sets the password of the user. /// - /// - /// The password is a required field. - /// [Required] public required string Password { get; set; } } diff --git a/ApiDto/Responses/AvailableProvidersResponse.cs b/ApiDto/Responses/AvailableOAuthProvidersResponse.cs similarity index 66% rename from ApiDto/Responses/AvailableProvidersResponse.cs rename to ApiDto/Responses/AvailableOAuthProvidersResponse.cs index c4e9dfc..bb8e13c 100644 --- a/ApiDto/Responses/AvailableProvidersResponse.cs +++ b/ApiDto/Responses/AvailableOAuthProvidersResponse.cs @@ -1,16 +1,17 @@ using Mirea.Api.Dto.Common; -using System; +using System.ComponentModel.DataAnnotations; namespace Mirea.Api.Dto.Responses; /// /// Represents the response containing information about available OAuth providers. /// -public class AvailableProvidersResponse +public class AvailableOAuthProvidersResponse { /// /// Gets or sets the name of the OAuth provider. /// + [Required] public required string ProviderName { get; set; } /// @@ -19,7 +20,8 @@ public class AvailableProvidersResponse public OAuthProvider Provider { get; set; } /// - /// Gets or sets the redirect URI for the OAuth provider. + /// Gets or sets the redirect URL for the OAuth provider's authorization process. /// - public required Uri Redirect { get; set; } + [Required] + public required string Redirect { get; set; } } \ No newline at end of file diff --git a/ApiDto/Responses/Configuration/CacheResponse.cs b/ApiDto/Responses/Configuration/CacheResponse.cs new file mode 100644 index 0000000..70671de --- /dev/null +++ b/ApiDto/Responses/Configuration/CacheResponse.cs @@ -0,0 +1,29 @@ +using Mirea.Api.Dto.Common; + +namespace Mirea.Api.Dto.Responses.Configuration; + +/// +/// Represents a response containing cache configuration details. +/// +public class CacheResponse +{ + /// + /// Gets or sets the type of cache database. + /// + public CacheType Type { get; set; } + + /// + /// Gets or sets the server address. + /// + public string? Server { get; set; } + + /// + /// Gets or sets the port number. + /// + public int Port { get; set; } + + /// + /// Gets or sets the password. + /// + public string? Password { get; set; } +} \ No newline at end of file diff --git a/ApiDto/Responses/Configuration/DatabaseResponse.cs b/ApiDto/Responses/Configuration/DatabaseResponse.cs new file mode 100644 index 0000000..76baad7 --- /dev/null +++ b/ApiDto/Responses/Configuration/DatabaseResponse.cs @@ -0,0 +1,49 @@ +using Mirea.Api.Dto.Common; + +namespace Mirea.Api.Dto.Responses.Configuration; + +/// +/// Represents a response containing database configuration details. +/// +public class DatabaseResponse +{ + /// + /// Gets or sets the type of database. + /// + public DatabaseType Type { get; set; } + + /// + /// Gets or sets the server address. + /// + public string? Server { get; set; } + + /// + /// Gets or sets the port number. + /// + public int Port { get; set; } + + /// + /// Gets or sets the database name. + /// + public string? Database { get; set; } + + /// + /// Gets or sets the username. + /// + public string? User { get; set; } + + /// + /// Gets or sets a value indicating whether SSL is enabled. + /// + public bool Ssl { get; set; } + + /// + /// Gets or sets the password. + /// + public string? Password { get; set; } + + /// + /// Gets or sets the path to database. Only for Sqlite + /// + public string? PathToDatabase { get; set; } +} \ No newline at end of file diff --git a/ApiDto/Responses/TotpKeyResponse.cs b/ApiDto/Responses/TotpKeyResponse.cs new file mode 100644 index 0000000..70a5865 --- /dev/null +++ b/ApiDto/Responses/TotpKeyResponse.cs @@ -0,0 +1,17 @@ +namespace Mirea.Api.Dto.Responses; + +/// +/// Represents the response containing the TOTP (Time-Based One-Time Password) key details. +/// +public class TotpKeyResponse +{ + /// + /// Gets or sets the secret key used for TOTP generation. + /// + public required string Secret { get; set; } + + /// + /// Gets or sets the image (QR code) representing the TOTP key. + /// + public required string Image { get; set; } +} \ No newline at end of file diff --git a/ApiDto/Responses/UserResponse.cs b/ApiDto/Responses/UserResponse.cs new file mode 100644 index 0000000..16f8a9d --- /dev/null +++ b/ApiDto/Responses/UserResponse.cs @@ -0,0 +1,35 @@ +using Mirea.Api.Dto.Common; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Mirea.Api.Dto.Responses; + +/// +/// Represents a response containing user information. +/// +public class UserResponse +{ + /// + /// Gets or sets the email address of the user. + /// + [Required] + public required string Email { get; set; } + + /// + /// Gets or sets the username of the user. + /// + [Required] + public required string Username { get; set; } + + /// + /// Gets or sets a value indicating whether the user has two-factor authentication enabled. + /// + [Required] + public bool TwoFactorAuthenticatorEnabled { get; set; } + + /// + /// Gets or sets a collection of OAuth providers used by the user. + /// + [Required] + public required IEnumerable UsedOAuthProviders { get; set; } +} \ No newline at end of file diff --git a/Endpoint/Configuration/Core/Startup/CacheConfiguration.cs b/Endpoint/Configuration/Core/Startup/CacheConfiguration.cs index ca3ddb8..7cac5c9 100644 --- a/Endpoint/Configuration/Core/Startup/CacheConfiguration.cs +++ b/Endpoint/Configuration/Core/Startup/CacheConfiguration.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Mirea.Api.Dto.Common; using Mirea.Api.Endpoint.Configuration.Model; -using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings; namespace Mirea.Api.Endpoint.Configuration.Core.Startup; @@ -10,7 +10,7 @@ public static class CacheConfiguration public static IServiceCollection AddCustomRedis(this IServiceCollection services, IConfiguration configuration, IHealthChecksBuilder? healthChecksBuilder = null) { var cache = configuration.Get()?.CacheSettings; - if (cache?.TypeDatabase != CacheSettings.CacheEnum.Redis) + if (cache?.TypeDatabase != CacheType.Redis) return services; services.AddStackExchangeRedisCache(options => diff --git a/Endpoint/Configuration/Core/Startup/SecureConfiguration.cs b/Endpoint/Configuration/Core/Startup/SecureConfiguration.cs index f9f7a81..c2671d7 100644 --- a/Endpoint/Configuration/Core/Startup/SecureConfiguration.cs +++ b/Endpoint/Configuration/Core/Startup/SecureConfiguration.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Mirea.Api.Dto.Common; using Mirea.Api.Endpoint.Common.Services.Security; using Mirea.Api.Endpoint.Configuration.Model; -using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings; using Mirea.Api.Security; using Mirea.Api.Security.Common.Interfaces; @@ -16,7 +16,7 @@ public static class SecureConfiguration services.AddSingleton(); - if (configuration.Get()?.CacheSettings?.TypeDatabase == CacheSettings.CacheEnum.Redis) + if (configuration.Get()?.CacheSettings?.TypeDatabase == CacheType.Redis) services.AddSingleton(); else services.AddSingleton(); diff --git a/Endpoint/Configuration/Model/GeneralSettings/CacheSettings.cs b/Endpoint/Configuration/Model/GeneralSettings/CacheSettings.cs index c5e5dc9..af0acb3 100644 --- a/Endpoint/Configuration/Model/GeneralSettings/CacheSettings.cs +++ b/Endpoint/Configuration/Model/GeneralSettings/CacheSettings.cs @@ -1,4 +1,5 @@ -using Mirea.Api.Endpoint.Configuration.Validation.Attributes; +using Mirea.Api.Dto.Common; +using Mirea.Api.Endpoint.Configuration.Validation.Attributes; using Mirea.Api.Endpoint.Configuration.Validation.Interfaces; namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings; @@ -6,18 +7,12 @@ namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings; [RequiredSettings] public class CacheSettings : IIsConfigured { - public enum CacheEnum - { - Memcached, - Redis - } - - public CacheEnum TypeDatabase { get; set; } + public CacheType TypeDatabase { get; set; } public string? ConnectionString { get; set; } public bool IsConfigured() { - return TypeDatabase == CacheEnum.Memcached || + return TypeDatabase == CacheType.Memcached || !string.IsNullOrEmpty(ConnectionString); } } \ No newline at end of file diff --git a/Endpoint/Configuration/Model/GeneralSettings/DbSettings.cs b/Endpoint/Configuration/Model/GeneralSettings/DbSettings.cs index 28921e9..c839fd4 100644 --- a/Endpoint/Configuration/Model/GeneralSettings/DbSettings.cs +++ b/Endpoint/Configuration/Model/GeneralSettings/DbSettings.cs @@ -1,4 +1,5 @@ using Mirea.Api.DataAccess.Persistence.Common; +using Mirea.Api.Dto.Common; using Mirea.Api.Endpoint.Configuration.Validation.Attributes; using Mirea.Api.Endpoint.Configuration.Validation.Interfaces; using System; @@ -9,22 +10,16 @@ namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings; [RequiredSettings] public class DbSettings : IIsConfigured { - public enum DatabaseEnum - { - Mysql, - Sqlite, - PostgresSql - } - public DatabaseEnum TypeDatabase { get; set; } + public DatabaseType TypeDatabase { get; set; } public required string ConnectionStringSql { get; set; } [JsonIgnore] public DatabaseProvider DatabaseProvider => TypeDatabase switch { - DatabaseEnum.PostgresSql => DatabaseProvider.Postgresql, - DatabaseEnum.Mysql => DatabaseProvider.Mysql, - DatabaseEnum.Sqlite => DatabaseProvider.Sqlite, + DatabaseType.PostgresSql => DatabaseProvider.Postgresql, + DatabaseType.Mysql => DatabaseProvider.Mysql, + DatabaseType.Sqlite => DatabaseProvider.Sqlite, _ => throw new ArgumentOutOfRangeException() }; diff --git a/Endpoint/Controllers/Configuration/SetupController.cs b/Endpoint/Controllers/Configuration/SetupController.cs index ecc21c2..e1780f2 100644 --- a/Endpoint/Controllers/Configuration/SetupController.cs +++ b/Endpoint/Controllers/Configuration/SetupController.cs @@ -4,15 +4,21 @@ 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; +using Mirea.Api.Dto.Responses; +using Mirea.Api.Dto.Responses.Configuration; using Mirea.Api.Endpoint.Common.Attributes; using Mirea.Api.Endpoint.Common.Exceptions; using Mirea.Api.Endpoint.Common.Interfaces; +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.Configuration.Validation.Validators; +using Mirea.Api.Security.Common.Domain; using Mirea.Api.Security.Services; using MySqlConnector; using Npgsql; @@ -36,7 +42,8 @@ public class SetupController( ISetupToken setupToken, IMaintenanceModeNotConfigureService notConfigureService, IMemoryCache cache, - PasswordHashService passwordHashService) : BaseController + PasswordHashService passwordHashService, + IOptionsSnapshot user) : BaseController { private const string CacheGeneralKey = "config_general"; private const string CacheAdminKey = "config_admin"; @@ -95,7 +102,12 @@ public class SetupController( return Ok(true); } - private ActionResult SetDatabase(string connectionString, DbSettings.DatabaseEnum databaseType) + [HttpGet("IsConfiguredToken")] + [TokenAuthentication] + public ActionResult IsConfiguredToken() => + Ok(true); + + private void SetDatabase(string connectionString, DatabaseType databaseType) where TConnection : class, IDbConnection, new() where TException : Exception { @@ -118,8 +130,6 @@ public class SetupController( TypeDatabase = databaseType }; GeneralConfig = general; - - return Ok(true); } catch (TException ex) { @@ -138,7 +148,20 @@ public class SetupController( if (request.Ssl) connectionString += ";SSL Mode=Require;"; - return SetDatabase(connectionString, DbSettings.DatabaseEnum.PostgresSql); + + SetDatabase(connectionString, DatabaseType.PostgresSql); + cache.Set("database", new DatabaseResponse + { + Type = DatabaseType.PostgresSql, + Database = request.Database, + Password = request.Password, + Ssl = request.Ssl, + Port = request.Port, + Server = request.Server, + User = request.User + }); + + return Ok(true); } [HttpPost("SetMysql")] @@ -152,7 +175,19 @@ public class SetupController( if (request.Ssl) connectionString += "SslMode=Require;"; - return SetDatabase(connectionString, DbSettings.DatabaseEnum.Mysql); + SetDatabase(connectionString, DatabaseType.Mysql); + cache.Set("database", new DatabaseResponse + { + Type = DatabaseType.Mysql, + Database = request.Database, + Password = request.Password, + Ssl = request.Ssl, + Port = request.Port, + Server = request.Server, + User = request.User + }); + + return Ok(true); } [HttpPost("SetSqlite")] @@ -179,14 +214,26 @@ public class SetupController( var filePath = Path.Combine(path, "database.db3"); var connectionString = $"Data Source={filePath}"; - var result = SetDatabase(connectionString, DbSettings.DatabaseEnum.Sqlite); + SetDatabase(connectionString, DatabaseType.Sqlite); foreach (var file in Directory.GetFiles(path)) System.IO.File.Delete(file); - return result; + cache.Set("database", new DatabaseResponse + { + Type = DatabaseType.Sqlite, + PathToDatabase = path + }); + + return Ok(true); } + [HttpGet("DatabaseConfiguration")] + [TokenAuthentication] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DatabaseConfiguration() => + cache.TryGetValue("database", out var response) ? Ok(response) : NoContent(); + [HttpPost("SetRedis")] [TokenAuthentication] [BadRequestResponse] @@ -205,10 +252,17 @@ public class SetupController( general.CacheSettings = new CacheSettings { ConnectionString = connectionString, - TypeDatabase = CacheSettings.CacheEnum.Redis + TypeDatabase = CacheType.Redis }; GeneralConfig = general; + cache.Set("cache", new CacheResponse + { + Type = CacheType.Redis, + Server = request.Server, + Password = request.Password, + Port = request.Port + }); return Ok(true); } catch (Exception ex) @@ -226,20 +280,29 @@ public class SetupController( general.CacheSettings = new CacheSettings { ConnectionString = null, - TypeDatabase = CacheSettings.CacheEnum.Memcached + TypeDatabase = CacheType.Memcached }; GeneralConfig = general; + cache.Set("cache", new CacheResponse + { + Type = CacheType.Memcached + }); return Ok(true); } + [HttpGet("CacheConfiguration")] + [TokenAuthentication] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CacheConfiguration() => + cache.TryGetValue("cache", out var response) ? Ok(response) : NoContent(); + [HttpPost("CreateAdmin")] [TokenAuthentication] [BadRequestResponse] public ActionResult CreateAdmin([FromBody] CreateUserRequest user) { - if (!PasswordHashService.HasPasswordInPolicySecurity(user.Password)) - throw new ControllerArgumentException("The password must be at least 8 characters long and contain at least one uppercase letter and one special character."); + new PasswordPolicyService(GeneralConfig.PasswordPolicy).ValidatePasswordOrThrow(user.Password); if (!MailAddress.TryCreate(user.Email, out _)) throw new ControllerArgumentException("The email address is incorrect."); @@ -258,6 +321,73 @@ public class SetupController( return Ok(true); } + [HttpGet("UpdateAdminConfiguration")] + [TokenAuthentication] + public ActionResult UpdateAdminConfiguration() + { + if (string.IsNullOrEmpty(user.Value.Email)) + return Ok(); + + if (!cache.TryGetValue(CacheAdminKey, out var admin)) + { + admin = user.Value; + 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; + + cache.Set(CacheAdminKey, admin); + return Ok(); + } + + [HttpGet("AdminConfiguration")] + [TokenAuthentication] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AdminConfiguration() => + cache.TryGetValue(CacheAdminKey, out var admin) ? Ok(new UserResponse() + { + Email = admin!.Email, + Username = admin.Username, + TwoFactorAuthenticatorEnabled = admin.TwoFactorAuthenticator != TwoFactorAuthenticator.None, + UsedOAuthProviders = admin.OAuthProviders == null ? [] : admin.OAuthProviders.Keys.Select(x => x.ConvertToDto()) + }) : NoContent(); + + [HttpGet("GenerateTotpKey")] + [TokenAuthentication] + public ActionResult GenerateTotpKey() + { + if (cache.TryGetValue("totpSecret", out var secret)) + return secret!; + + secret = GeneratorKey.GenerateAlphaNumericBase32Compatible(16); + cache.Set("totpSecret", secret); + return secret; + } + + [HttpGet("VerifyTotp")] + [TokenAuthentication] + public ActionResult VerifyTotp([FromQuery] string code) + { + var isCorrect = cache.TryGetValue("totpSecret", out var secret) && + new TotpService(secret!).VerifyToken(code); + + if (!isCorrect || !cache.TryGetValue(CacheAdminKey, out var admin)) + return false; + + admin!.Secret = secret; + admin.TwoFactorAuthenticator = TwoFactorAuthenticator.Totp; + cache.Set(CacheAdminKey, admin); + + return true; + } + [HttpPost("SetLogging")] [TokenAuthentication] [BadRequestResponse] @@ -292,9 +422,22 @@ public class SetupController( general.LogSettings = settings; GeneralConfig = general; + cache.Set("logging", new LoggingRequest + { + EnableLogToFile = settings.EnableLogToFile, + LogFileName = settings.LogFileName, + LogFilePath = settings.LogFilePath + }); + return true; } + [HttpGet("LoggingConfiguration")] + [TokenAuthentication] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult LoggingConfiguration() => + cache.TryGetValue("logging", out var data) ? Ok(data) : NoContent(); + [HttpPost("SetEmail")] [TokenAuthentication] [BadRequestResponse] @@ -318,9 +461,16 @@ public class SetupController( general.EmailSettings = settings; GeneralConfig = general; + cache.Set("email", settings); return true; } + [HttpGet("EmailConfiguration")] + [TokenAuthentication] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult EmailConfiguration() => + cache.TryGetValue("email", out var data) ? Ok(data) : NoContent(); + [HttpPost("SetSchedule")] [TokenAuthentication] [BadRequestResponse] @@ -349,8 +499,20 @@ public class SetupController( GeneralConfig = general; + cache.Set("schedule", new ScheduleConfigurationRequest() + { + StartTerm = general.ScheduleSettings.StartTerm, + CronUpdateSchedule = general.ScheduleSettings.CronUpdateSchedule + }); return true; } + + [HttpGet("ScheduleConfiguration")] + [TokenAuthentication] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult ScheduleConfiguration() => + cache.TryGetValue("schedule", out var data) ? Ok(data) : NoContent(); + [HttpPost("SetPasswordPolicy")] [TokenAuthentication] public ActionResult SetPasswordPolicy([FromBody] PasswordPolicy? policy = null)