Compare commits

...

35 Commits

Author SHA1 Message Date
5cc54eac44 feat: add a method for getting data from OAuth
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m20s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m24s
2024-12-18 07:40:07 +03:00
e9ff1cabe8 feat: add a return of the data that has been configured 2024-12-18 07:39:17 +03:00
fd578aa61e fix: add condition for token 2024-12-18 07:32:00 +03:00
cff42d0a31 feat: add password policy to general config 2024-12-18 07:31:16 +03:00
8250957b85 refactor: use another origin 2024-12-18 07:30:47 +03:00
39208037f0 fix: add method for ignoring mode attribute 2024-12-18 07:30:23 +03:00
5e072d88c2 fix: converter dto 2024-12-18 07:29:58 +03:00
25eddbe776 feat: add generator totp qr-code 2024-12-18 07:29:31 +03:00
74ba4e901a fix: replace localhost to 127.0.0.1 2024-12-18 07:29:05 +03:00
e760ddae0a feat: give the user the ability to make a password policy 2024-12-18 07:27:57 +03:00
598ebabc5c sec: use HMAC to encrypt state 2024-12-18 07:24:33 +03:00
08aeb7ea3c sec: get links to the backend to initiate the receipt of provider data 2024-12-18 07:23:23 +03:00
182235c4cd feat: add generator base32 for totp 2024-12-18 07:14:04 +03:00
5437623a20 build: update ref 2024-12-18 07:13:27 +03:00
2c09122971 fix: add cookie expire time 2024-11-04 03:15:13 +03:00
503f5792fb docs: add comment 2024-11-04 03:14:42 +03:00
95627003e5 refactor: change the error 2024-11-04 03:14:17 +03:00
a96073d44d feat: add available providers list 2024-11-04 02:59:51 +03:00
5f36e0f75b docs: update 2024-11-04 02:39:45 +03:00
e977de3e4f feat: add authorize in OAuth 2024-11-04 02:39:10 +03:00
65d928ec2d fix: remove authorize 2024-11-04 02:36:22 +03:00
713bbfa16f feat: add calculate correct api url 2024-11-04 02:35:43 +03:00
6b5eda7756 fix: remove the latest api 2024-11-04 02:34:50 +03:00
dbd9e1a070 refactor: change Name to NameIdentifier 2024-11-04 02:33:56 +03:00
0dda336de1 fix: logout for all users to delete cookies 2024-11-04 02:32:13 +03:00
727f5c276e refactor: move files 2024-11-02 23:34:23 +03:00
db70e4dd96 refactor: change log text 2024-11-02 22:10:46 +03:00
6831d9c708 fix: return bool instead 2024-11-02 22:09:40 +03:00
1b24954c3e refactor: change int to string for Id 2024-11-02 20:21:46 +03:00
c5ba1cfcca refactor: transfer two factor method to security 2024-11-02 01:09:15 +03:00
3811d879ab refactor: return next step from security 2024-11-02 01:06:58 +03:00
61dc0a8bc4 feat: add converter for two factor 2024-11-02 01:05:24 +03:00
b3b00aa9e1 refator: move converter to MapperDto 2024-11-02 00:59:37 +03:00
6c9af942f4 refactor: change token to instance token 2024-11-02 00:51:27 +03:00
23f74b3bdf refactor: change name enums 2024-11-02 00:50:10 +03:00
56 changed files with 1532 additions and 208 deletions

48
.env
View File

@ -21,6 +21,8 @@ PATH_TO_SAVE=
# The actual sub path to the api
# string
# (optional)
# If the specified path ends with "/api", the system will avoid duplicating "api" in the final URL.
# This allows flexible API structuring, especially when running behind a reverse proxy or in containerized environments.
ACTUAL_SUB_PATH=
# The sub path to the swagger
@ -114,4 +116,48 @@ SECURITY_HASH_TOKEN=
# The size of the salt used to hash passwords
# integer
# The salt is a random value added to the password before hashing to prevent the use of rainbow hash tables and other attacks.
SECURITY_SALT_SIZE=16
SECURITY_SALT_SIZE=16
### OAuth2
#### GOOGLE
# The client ID for Google OAuth
# string
# This is the client ID provided by Google when you register your application for OAuth.
# It's necessary for enabling Google login functionality.
GOOGLE_CLIENT_ID=
# The client secret for Google OAuth
# string
# This is the client secret provided by Google, used alongside the client ID to authenticate your application.
# Make sure to keep it confidential.
GOOGLE_CLIENT_SECRET=
#### Yandex
# The client ID for Yandex OAuth
# string
# This is the client ID provided by Yandex when you register your application for OAuth.
# It's required for enabling Yandex login functionality.
YANDEX_CLIENT_ID=
# The client secret for Yandex OAuth
# string
# This is the client secret provided by Yandex, used alongside the client ID to authenticate your application.
# Keep it confidential to ensure the security of your app.
YANDEX_CLIENT_SECRET=
#### MailRu
# The client ID for MailRu OAuth
# string
# This is the client ID provided by MailRu (Mail.ru Group) when you register your application for OAuth.
# It's necessary for enabling MailRu login functionality.
MAILRU_CLIENT_ID=
# The client secret for MailRu OAuth
# string
# This is the client secret provided by MailRu, used alongside the client ID to authenticate your application.
# Keep it confidential to ensure the security of your app.
MAILRU_CLIENT_SECRET=

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Specifies the types of caching mechanisms available.
/// </summary>
public enum CacheType
{
/// <summary>
/// Memcached caching type.
/// </summary>
Memcached,
/// <summary>
/// Redis caching type.
/// </summary>
Redis
}

View File

@ -0,0 +1,22 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Specifies the types of databases supported.
/// </summary>
public enum DatabaseType
{
/// <summary>
/// MySQL database type.
/// </summary>
Mysql,
/// <summary>
/// SQLite database type.
/// </summary>
Sqlite,
/// <summary>
/// PostgreSQL database type.
/// </summary>
PostgresSql
}

View File

@ -0,0 +1,22 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents different OAuth providers for authentication.
/// </summary>
public enum OAuthProvider
{
/// <summary>
/// OAuth provider for Google.
/// </summary>
Google,
/// <summary>
/// OAuth provider for Yandex.
/// </summary>
Yandex,
/// <summary>
/// OAuth provider for Mail.ru.
/// </summary>
MailRu
}

View File

@ -0,0 +1,32 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents the password policy settings for user authentication.
/// </summary>
public class PasswordPolicy
{
/// <summary>
/// Gets or sets the minimum length required for a password.
/// </summary>
public int MinimumLength { get; set; }
/// <summary>
/// Gets or sets a value indicating whether at least one letter is required in the password.
/// </summary>
public bool RequireLetter { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the password must contain both lowercase and uppercase letters.
/// </summary>
public bool RequireLettersDifferentCase { get; set; }
/// <summary>
/// Gets or sets a value indicating whether at least one digit is required in the password.
/// </summary>
public bool RequireDigit { get; set; }
/// <summary>
/// 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

@ -1,9 +1,9 @@
namespace Mirea.Api.Dto.Responses;
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents the steps required after a login attempt.
/// </summary>
public enum AuthenticationStep
public enum TwoFactorAuthentication
{
/// <summary>
/// No additional steps required; the user is successfully logged in.

View File

@ -10,27 +10,18 @@ public class CreateUserRequest
/// <summary>
/// Gets or sets the email address of the user.
/// </summary>
/// <remarks>
/// The email address is a required field.
/// </remarks>
[Required]
public required string Email { get; set; }
/// <summary>
/// Gets or sets the username of the user.
/// </summary>
/// <remarks>
/// The username is a required field.
/// </remarks>
[Required]
public required string Username { get; set; }
/// <summary>
/// Gets or sets the password of the user.
/// </summary>
/// <remarks>
/// The password is a required field.
/// </remarks>
[Required]
public required string Password { get; set; }
}

View File

@ -0,0 +1,19 @@
using Mirea.Api.Dto.Common;
namespace Mirea.Api.Dto.Requests;
/// <summary>
/// Represents a request for verifying two-factor authentication.
/// </summary>
public class TwoFactorAuthRequest
{
/// <summary>
/// Gets or sets the two-factor authentication code provided by the user.
/// </summary>
public required string Code { get; set; }
/// <summary>
/// Gets or sets the type of the two-factor authentication method used (e.g., TOTP, Email).
/// </summary>
public TwoFactorAuthentication Method { get; set; }
}

View File

@ -0,0 +1,27 @@
using Mirea.Api.Dto.Common;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the response containing information about available OAuth providers.
/// </summary>
public class AvailableOAuthProvidersResponse
{
/// <summary>
/// Gets or sets the name of the OAuth provider.
/// </summary>
[Required]
public required string ProviderName { get; set; }
/// <summary>
/// Gets or sets the enum value representing the OAuth provider.
/// </summary>
public OAuthProvider Provider { get; set; }
/// <summary>
/// Gets or sets the redirect URL for the OAuth provider's authorization process.
/// </summary>
[Required]
public required string Redirect { get; set; }
}

View File

@ -0,0 +1,29 @@
using Mirea.Api.Dto.Common;
namespace Mirea.Api.Dto.Responses.Configuration;
/// <summary>
/// Represents a response containing cache configuration details.
/// </summary>
public class CacheResponse
{
/// <summary>
/// Gets or sets the type of cache database.
/// </summary>
public CacheType Type { get; set; }
/// <summary>
/// Gets or sets the server address.
/// </summary>
public string? Server { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
public int Port { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string? Password { get; set; }
}

View File

@ -0,0 +1,49 @@
using Mirea.Api.Dto.Common;
namespace Mirea.Api.Dto.Responses.Configuration;
/// <summary>
/// Represents a response containing database configuration details.
/// </summary>
public class DatabaseResponse
{
/// <summary>
/// Gets or sets the type of database.
/// </summary>
public DatabaseType Type { get; set; }
/// <summary>
/// Gets or sets the server address.
/// </summary>
public string? Server { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
public int Port { get; set; }
/// <summary>
/// Gets or sets the database name.
/// </summary>
public string? Database { get; set; }
/// <summary>
/// Gets or sets the username.
/// </summary>
public string? User { get; set; }
/// <summary>
/// Gets or sets a value indicating whether SSL is enabled.
/// </summary>
public bool Ssl { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Gets or sets the path to database. Only for Sqlite
/// </summary>
public string? PathToDatabase { get; set; }
}

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the response containing the TOTP (Time-Based One-Time Password) key details.
/// </summary>
public class TotpKeyResponse
{
/// <summary>
/// Gets or sets the secret key used for TOTP generation.
/// </summary>
public required string Secret { get; set; }
/// <summary>
/// Gets or sets the image (QR code) representing the TOTP key.
/// </summary>
public required string Image { get; set; }
}

View File

@ -0,0 +1,35 @@
using Mirea.Api.Dto.Common;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents a response containing user information.
/// </summary>
public class UserResponse
{
/// <summary>
/// Gets or sets the email address of the user.
/// </summary>
[Required]
public required string Email { get; set; }
/// <summary>
/// Gets or sets the username of the user.
/// </summary>
[Required]
public required string Username { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user has two-factor authentication enabled.
/// </summary>
[Required]
public bool TwoFactorAuthenticatorEnabled { get; set; }
/// <summary>
/// Gets or sets a collection of OAuth providers used by the user.
/// </summary>
[Required]
public required IEnumerable<OAuthProvider> UsedOAuthProviders { get; set; }
}

View File

@ -2,5 +2,5 @@
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)]
public class MaintenanceModeIgnoreAttribute : Attribute;

View File

@ -0,0 +1,27 @@
using Mirea.Api.Dto.Responses;
using Mirea.Api.Security.Common.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class AvailableProvidersConverter
{
public static Dto.Common.OAuthProvider ConvertToDto(this OAuthProvider provider) =>
provider switch
{
OAuthProvider.Google => Dto.Common.OAuthProvider.Google,
OAuthProvider.Yandex => Dto.Common.OAuthProvider.Yandex,
OAuthProvider.MailRu => Dto.Common.OAuthProvider.MailRu,
_ => throw new ArgumentOutOfRangeException(nameof(provider), provider, null)
};
public static List<AvailableOAuthProvidersResponse> ConvertToDto(this (OAuthProvider Provider, Uri Redirect)[] data) =>
data.Select(x => new AvailableOAuthProvidersResponse()
{
ProviderName = Enum.GetName(x.Provider)!,
Provider = x.Provider.ConvertToDto(),
Redirect = x.Redirect.ToString()
}).ToList();
}

View File

@ -2,7 +2,7 @@
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.Services;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class PairPeriodTimeConverter
{

View File

@ -0,0 +1,23 @@
using Mirea.Api.Dto.Common;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class PasswordPolicyConverter
{
public static Security.Common.Domain.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) =>
new()
{
MinimumLength = policy.MinimumLength,
RequireLetter = policy.RequireLetter,
RequireDigit = policy.RequireDigit,
RequireSpecialCharacter = policy.RequireSpecialCharacter,
RequireLettersDifferentCase = policy.RequireLettersDifferentCase
};
}

View File

@ -0,0 +1,24 @@
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) =>
authenticator switch
{
TwoFactorAuthenticator.None => TwoFactorAuthentication.None,
TwoFactorAuthenticator.Totp => TwoFactorAuthentication.TotpRequired,
_ => throw new ArgumentOutOfRangeException(nameof(authenticator), authenticator, null)
};
public static TwoFactorAuthenticator ConvertFromDto(this TwoFactorAuthentication authentication) =>
authentication switch
{
TwoFactorAuthentication.None => TwoFactorAuthenticator.None,
TwoFactorAuthentication.TotpRequired => TwoFactorAuthenticator.Totp,
_ => throw new ArgumentOutOfRangeException(nameof(authentication), authentication, null)
};
}

View File

@ -33,7 +33,7 @@ public class JwtTokenService : IAccessToken
SigningCredentials = signingCredentials,
Subject = new ClaimsIdentity(
[
new Claim(ClaimTypes.Name, userId),
new Claim(ClaimTypes.NameIdentifier, userId),
// todo: get role by userId
new Claim(ClaimTypes.Role, "")
]),

View File

@ -6,6 +6,9 @@ namespace Mirea.Api.Endpoint.Common.Services;
public static class UrlHelper
{
public static string GetCurrentScheme(this HttpContext context) =>
context.Request.Headers["X-Forwarded-Proto"].FirstOrDefault() ?? context.Request.Scheme;
public static string GetCurrentDomain(this HttpContext context) =>
context.Request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? context.Request.Host.Host;
@ -30,17 +33,23 @@ public static class UrlHelper
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < parts.Length; i++)
{
if (!parts[i].Equals("api", StringComparison.CurrentCultureIgnoreCase)) continue;
parts = parts.Take(i).Concat(parts.Skip(i + 1)).ToArray();
break;
}
if (parts[^1].Equals("api", StringComparison.CurrentCultureIgnoreCase))
parts = parts.Take(parts.Length - 1).ToArray();
return CreateSubPath(string.Join("/", parts));
}
}
public static string GetSubPathSwagger => CreateSubPath(Environment.GetEnvironmentVariable("SWAGGER_SUB_PATH"));
public static string GetApiUrl(this HttpContext context, string apiPath = "")
{
var scheme = GetCurrentScheme(context);
var domain = GetCurrentDomain(context).TrimEnd('/').Replace("localhost", "127.0.0.1");
var port = context.Request.Host.Port;
var portString = port.HasValue && port != 80 && port != 443 ? $":{port}" : string.Empty;
return $"{scheme}://{domain}{portString}{GetSubPathWithoutFirstApiName}{apiPath.Trim('/')}";
}
}

View File

@ -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<GeneralConfig>()?.CacheSettings;
if (cache?.TypeDatabase != CacheSettings.CacheEnum.Redis)
if (cache?.TypeDatabase != CacheType.Redis)
return services;
services.AddStackExchangeRedisCache(options =>

View File

@ -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<IRevokedToken, MemoryRevokedTokenService>();
if (configuration.Get<GeneralConfig>()?.CacheSettings?.TypeDatabase == CacheSettings.CacheEnum.Redis)
if (configuration.Get<GeneralConfig>()?.CacheSettings?.TypeDatabase == CacheType.Redis)
services.AddSingleton<ICacheService, DistributedCacheService>();
else
services.AddSingleton<ICacheService, MemoryCacheService>();

View File

@ -1,5 +1,6 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Security.Common.Domain;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
@ -17,9 +18,11 @@ public class Admin : ISaveSettings
public required string Email { get; set; }
public required string PasswordHash { get; set; }
public required string Salt { get; set; }
public SecondFactor SecondFactor { get; set; } = SecondFactor.None;
public TwoFactorAuthenticator TwoFactorAuthenticator { get; set; } = TwoFactorAuthenticator.None;
public string? Secret { get; set; }
public Dictionary<OAuthProvider, OAuthUser>? OAuthProviders { get; set; }
public void SaveSetting()
{
File.WriteAllText(FilePath, JsonSerializer.Serialize(this));

View File

@ -1,5 +1,6 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Security.Common.Domain;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
@ -18,6 +19,8 @@ public class GeneralConfig : ISaveSettings
public ScheduleSettings? ScheduleSettings { get; set; }
public EmailSettings? EmailSettings { get; set; }
public LogSettings? LogSettings { get; set; }
public PasswordPolicy PasswordPolicy { get; set; } = new();
public string? SecretForwardToken { get; set; }
public void SaveSetting()

View File

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

View File

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

View File

@ -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;
@ -25,6 +31,7 @@ using System.Linq;
using System.Net.Mail;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy;
namespace Mirea.Api.Endpoint.Controllers.Configuration;
@ -35,7 +42,8 @@ public class SetupController(
ISetupToken setupToken,
IMaintenanceModeNotConfigureService notConfigureService,
IMemoryCache cache,
PasswordHashService passwordHashService) : BaseController
PasswordHashService passwordHashService,
IOptionsSnapshot<Admin> user) : BaseController
{
private const string CacheGeneralKey = "config_general";
private const string CacheAdminKey = "config_admin";
@ -69,7 +77,17 @@ public class SetupController(
[HttpGet("CheckToken")]
public ActionResult<bool> CheckToken([FromQuery] string token)
{
if (!setupToken.MatchToken(Convert.FromBase64String(token)))
byte[] tokenBase64;
try
{
tokenBase64 = Convert.FromBase64String(token);
}
catch (FormatException)
{
throw new ControllerArgumentException("A token of the wrong format.");
}
if (!setupToken.MatchToken(tokenBase64))
return Unauthorized("The token is not valid");
Response.Cookies.Append(TokenAuthenticationAttribute.AuthToken, token, new CookieOptions
@ -84,7 +102,12 @@ public class SetupController(
return Ok(true);
}
private ActionResult<bool> SetDatabase<TConnection, TException>(string connectionString, DbSettings.DatabaseEnum databaseType)
[HttpGet("IsConfiguredToken")]
[TokenAuthentication]
public ActionResult<bool> IsConfiguredToken() =>
Ok(true);
private void SetDatabase<TConnection, TException>(string connectionString, DatabaseType databaseType)
where TConnection : class, IDbConnection, new()
where TException : Exception
{
@ -107,8 +130,6 @@ public class SetupController(
TypeDatabase = databaseType
};
GeneralConfig = general;
return Ok(true);
}
catch (TException ex)
{
@ -127,7 +148,20 @@ public class SetupController(
if (request.Ssl)
connectionString += ";SSL Mode=Require;";
return SetDatabase<NpgsqlConnection, NpgsqlException>(connectionString, DbSettings.DatabaseEnum.PostgresSql);
SetDatabase<NpgsqlConnection, NpgsqlException>(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")]
@ -141,7 +175,19 @@ public class SetupController(
if (request.Ssl)
connectionString += "SslMode=Require;";
return SetDatabase<MySqlConnection, MySqlException>(connectionString, DbSettings.DatabaseEnum.Mysql);
SetDatabase<MySqlConnection, MySqlException>(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")]
@ -168,14 +214,26 @@ public class SetupController(
var filePath = Path.Combine(path, "database.db3");
var connectionString = $"Data Source={filePath}";
var result = SetDatabase<SqliteConnection, SqliteException>(connectionString, DbSettings.DatabaseEnum.Sqlite);
SetDatabase<SqliteConnection, SqliteException>(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<DatabaseRequest> DatabaseConfiguration() =>
cache.TryGetValue<DatabaseResponse>("database", out var response) ? Ok(response) : NoContent();
[HttpPost("SetRedis")]
[TokenAuthentication]
[BadRequestResponse]
@ -194,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)
@ -215,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<CacheResponse> CacheConfiguration() =>
cache.TryGetValue<CacheResponse>("cache", out var response) ? Ok(response) : NoContent();
[HttpPost("CreateAdmin")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<string> 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.");
@ -247,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<Admin>(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<UserResponse> AdminConfiguration() =>
cache.TryGetValue<Admin>(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<string> GenerateTotpKey()
{
if (cache.TryGetValue<string>("totpSecret", out var secret))
return secret!;
secret = GeneratorKey.GenerateAlphaNumericBase32Compatible(16);
cache.Set("totpSecret", secret);
return secret;
}
[HttpGet("VerifyTotp")]
[TokenAuthentication]
public ActionResult<bool> VerifyTotp([FromQuery] string code)
{
var isCorrect = cache.TryGetValue<string>("totpSecret", out var secret) &&
new TotpService(secret!).VerifyToken(code);
if (!isCorrect || !cache.TryGetValue<Admin>(CacheAdminKey, out var admin))
return false;
admin!.Secret = secret;
admin.TwoFactorAuthenticator = TwoFactorAuthenticator.Totp;
cache.Set(CacheAdminKey, admin);
return true;
}
[HttpPost("SetLogging")]
[TokenAuthentication]
[BadRequestResponse]
@ -281,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<LoggingRequest> LoggingConfiguration() =>
cache.TryGetValue<LoggingRequest>("logging", out var data) ? Ok(data) : NoContent();
[HttpPost("SetEmail")]
[TokenAuthentication]
[BadRequestResponse]
@ -307,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<EmailRequest> EmailConfiguration() =>
cache.TryGetValue<EmailRequest>("email", out var data) ? Ok(data) : NoContent();
[HttpPost("SetSchedule")]
[TokenAuthentication]
[BadRequestResponse]
@ -338,9 +499,35 @@ 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<ScheduleConfigurationRequest> ScheduleConfiguration() =>
cache.TryGetValue<ScheduleConfigurationRequest>("schedule", out var data) ? Ok(data) : NoContent();
[HttpPost("SetPasswordPolicy")]
[TokenAuthentication]
public ActionResult<bool> SetPasswordPolicy([FromBody] PasswordPolicy? policy = null)
{
GeneralConfig.PasswordPolicy = policy?.ConvertFromDto() ?? new Security.Common.Domain.PasswordPolicy();
cache.Set("password", true);
return true;
}
[HttpGet("PasswordPolicyConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<PasswordPolicy> PasswordPolicyConfiguration() =>
cache.TryGetValue("password", out _) ? Ok(GeneralConfig.PasswordPolicy) : NoContent();
[HttpPost("Submit")]
[TokenAuthentication]
[BadRequestResponse]

View File

@ -8,17 +8,21 @@ using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
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.Security.Claims;
using System.Threading.Tasks;
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, PasswordHashService passwordService) : BaseController
public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<GeneralConfig> generalConfig, AuthService auth, PasswordHashService passwordService, OAuthService oAuthService) : BaseController
{
private CookieOptionsParameters GetCookieParams() =>
new()
@ -27,39 +31,172 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
};
private static string GenerateHtmlResponse(string title, string message, OAuthProvider? provider, bool isError = false)
{
string messageColor = isError ? "red" : "white";
string script = "<script>setTimeout(()=>{if(window.opener){window.opener.postMessage(" +
"{success:" + !isError +
",provider:'" + (provider == null ? "null" : (int)provider) +
"',providerName:'" + (provider == null ? "null" : Enum.GetName(provider.Value)) +
"',message:'" + message.Replace("'", "\\'") +
"'},'*');}window.close();}, 5000);</script>";
return $"<!DOCTYPE html><html lang=ru><head><meta charset=UTF-8><meta content=\"width=device-width,initial-scale=1\"name=viewport><link href=\"https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap\"rel=stylesheet><style>body{{background-color:#121212;color:#fff;font-family:Roboto,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;text-align:center}}.container{{max-width:600px;padding:20px;border-radius:8px;background-color:#1e1e1e;box-shadow:0 4px 20px rgba(0,0,0,.5)}}h1{{font-size:24px;margin-bottom:20px}}p{{font-size:16px;color:{messageColor}}}</style><title>{title}</title></head><body><div class=container><h1>{title}</h1><p>{message}<p style=font-size:14px;color:silver;>Это информационная страница. Вы можете закрыть её.</div>{script}</body></html>";
}
[HttpGet("OAuth2")]
[BadRequestResponse]
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
[Produces("text/html")]
[MaintenanceModeIgnore]
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;
try
{
(provider, oAuthUser) = await oAuthService.LoginOAuth(HttpContext, GetCookieParams(),
HttpContext.GetApiUrl(Url.Action("OAuth2")!), code, state);
}
catch (Exception e)
{
title = "Произошла ошибка при общении с провайдером OAuth!";
message = e.Message;
return Content(GenerateHtmlResponse(title, message, null, true), "text/html");
}
var userEntity = user.Value;
if (userId != null)
{
userEntity.OAuthProviders ??= new Dictionary<OAuthProvider, OAuthUser>();
if (!userEntity.OAuthProviders.TryAdd(provider, oAuthUser))
{
title = "Ошибка связи аккаунта!";
message = "Этот OAuth провайдер уже связан с вашей учетной записью. Пожалуйста, используйте другого провайдера или удалите связь с аккаунтом.";
return Content(GenerateHtmlResponse(title, message, provider, true), "text/html");
}
userEntity.SaveSetting();
title = "Учетная запись успешно связана.";
message = "Вы успешно связали свою учетную запись с провайдером OAuth. Вы можете продолжить использовать приложение.";
return Content(GenerateHtmlResponse(title, message, provider), "text/html");
}
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");
}
/// <summary>
/// Initiates the OAuth2 authorization process for the selected provider.
/// </summary>
/// <remarks>
/// 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>
/// <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)
{
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);
}
/// <summary>
/// Retrieves a list of available OAuth providers with their corresponding authorization URLs.
/// </summary>
/// <remarks>
/// This allows the client to fetch all possible OAuth options and the URLs required to initiate authorization.
/// </remarks>
/// <returns>A list of available providers and their redirect URLs.</returns>
[HttpGet("AvailableProviders")]
[MaintenanceModeIgnore]
public ActionResult<List<AvailableOAuthProvidersResponse>> AvailableProviders() =>
Ok(oAuthService
.GetAvailableProviders(HttpContext, HttpContext.GetApiUrl(Url.Action("AuthorizeOAuth2")!))
.ConvertToDto());
/// <summary>
/// Logs in a user using their username or email and password.
/// </summary>
/// <param name="request">The login request containing username/email and password.</param>
/// <returns>A TwoFactorAuthentication token if the login is successful; otherwise, a BadRequest response.</returns>
[HttpPost("Login")]
[BadRequestResponse]
public async Task<ActionResult<AuthenticationStep>> Login([FromBody] LoginRequest request)
public async Task<ActionResult<TwoFactorAuthentication>> Login([FromBody] LoginRequest request)
{
var userEntity = user.Value;
if (!userEntity.Username.Equals(request.Username, StringComparison.OrdinalIgnoreCase) &&
!userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase))
return BadRequest("Invalid username/email or password");
return Unauthorized("Authentication failed. Please check your credentials.");
var tokenResult = await auth.LoginAsync(
GetCookieParams(),
new User
{
Id = 1,
Id = 1.ToString(),
Username = userEntity.Username,
Email = userEntity.Email,
PasswordHash = userEntity.PasswordHash,
Salt = userEntity.Salt,
SecondFactor = userEntity.SecondFactor,
SecondFactorToken = userEntity.Secret
TwoFactorAuthenticator = userEntity.TwoFactorAuthenticator,
SecondFactorToken = userEntity.Secret,
OAuthProviders = userEntity.OAuthProviders
},
HttpContext, request.Password);
return Ok(tokenResult ? AuthenticationStep.None : AuthenticationStep.TotpRequired);
return Ok(tokenResult.ConvertToDto());
}
[HttpGet("Login")]
/// <summary>
/// Performs two-factor authentication for the user.
/// </summary>
/// <param name="request">The request containing the method and code for two-factor authentication.</param>
/// <returns>A boolean indicating whether the two-factor authentication was successful.</returns>
[HttpPost("2FA")]
[BadRequestResponse]
public async Task<ActionResult<AuthenticationStep>> Login([FromQuery] string code)
public async Task<ActionResult<bool>> TwoFactorAuth([FromBody] TwoFactorAuthRequest request)
{
var tokenResult = await auth.LoginAsync(GetCookieParams(), HttpContext, code);
return Ok(tokenResult ? AuthenticationStep.None : AuthenticationStep.TotpRequired);
var tokenResult = await auth.LoginAsync(GetCookieParams(), HttpContext, request.Method.ConvertFromDto(), request.Code);
return Ok(tokenResult);
}
/// <summary>
@ -80,12 +217,9 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
/// </summary>
/// <returns>An Ok response if the logout was successful.</returns>
[HttpGet("Logout")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Authorize]
public async Task<ActionResult> Logout()
{
await auth.LogoutAsync(GetCookieParams(), HttpContext);
return Ok();
}
@ -104,13 +238,16 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
[BadRequestResponse]
public ActionResult<string> RenewPassword([FromBody] string? password = null)
{
var passwordPolicy = generalConfig.Value.PasswordPolicy;
var passwordPolicyService = new PasswordPolicyService(passwordPolicy);
if (string.IsNullOrEmpty(password))
password = string.Empty;
else if (!PasswordHashService.HasPasswordInPolicySecurity(password))
throw new ControllerArgumentException("The password must be at least 8 characters long and contain at least one uppercase letter and one special character.");
else
passwordPolicyService.ValidatePasswordOrThrow(password);
while (!PasswordHashService.HasPasswordInPolicySecurity(password))
password = GeneratorKey.GenerateAlphaNumeric(16, includes: "!@#%^");
while (!passwordPolicyService.TryValidatePassword(password))
password = GeneratorKey.GenerateAlphaNumeric(passwordPolicy.MinimumLength + 2, includes: "!@#%^");
var (salt, hash) = passwordService.HashPassword(password);

View File

@ -8,7 +8,7 @@ using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Configuration.Model;
using System;
using System.Collections.Generic;

View File

@ -0,0 +1,84 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Mirea.Api.Dto.Common;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Configuration.Model;
using QRCoder;
using System;
using System.Drawing;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
public class SecurityController(IOptionsSnapshot<GeneralConfig> generalConfig) : BaseController
{
/// <summary>
/// Generates an SVG QR code for TOTP setup with customizable colors and size.
/// </summary>
/// <param name="totpKey">The TOTP secret key to embed in the QR code.</param>
/// <param name="label">The label to display with the QR code (usually username or email).</param>
/// <param name="backgroundColor">Background color for the QR code in hex format (e.g., "#FFFFFF" for white). Defaults to transparent.</param>
/// <param name="foregroundColor">Foreground color for the QR code in hex format (e.g., "#000000" for black), defaults to black.</param>
/// <param name="size">The pixel size of the QR code image (width and height).</param>
/// <param name="errorCorrectionLevel">Error correction level (low, medium, high, or very high). Valid values: L, M, Q, H.</param>
/// <returns>An SVG string of the generated QR code.</returns>
[HttpGet("GenerateTotpQrCode")]
[Produces("image/svg+xml")]
[MaintenanceModeIgnore]
public IActionResult GenerateTotpQrCode(
[FromQuery] string totpKey,
[FromQuery] string label,
[FromQuery] string? backgroundColor = null,
[FromQuery] string foregroundColor = "#000000",
[FromQuery] int size = 250,
[FromQuery] string? errorCorrectionLevel = "M")
{
try
{
var bgColor = string.IsNullOrEmpty(backgroundColor) ? Color.Transparent : ColorTranslator.FromHtml(backgroundColor);
var fgColor = ColorTranslator.FromHtml(foregroundColor);
var eccLevel = errorCorrectionLevel?.ToUpper() switch
{
"L" => QRCodeGenerator.ECCLevel.L,
"Q" => QRCodeGenerator.ECCLevel.Q,
"H" => QRCodeGenerator.ECCLevel.H,
_ => QRCodeGenerator.ECCLevel.M
};
var issuer = Uri.EscapeDataString("Mirea Schedule");
// Generate TOTP URI (otpauth://totp/issuer:username?secret=KEY&issuer=issuer)
var totpUri = $"otpauth://totp/{issuer}:{label}?secret={totpKey}&issuer={issuer}";
using var qrGenerator = new QRCodeGenerator();
var qrCodeData = qrGenerator.CreateQrCode(totpUri, eccLevel);
using var qrCode = new SvgQRCode(qrCodeData);
var svgImage = qrCode.GetGraphic(
pixelsPerModule: size / 25,
darkColorHex: $"#{fgColor.R:X2}{fgColor.G:X2}{fgColor.B:X2}",
lightColorHex: $"#{bgColor.R:X2}{bgColor.G:X2}{bgColor.B:X2}"
);
return Content(svgImage, "image/svg+xml");
}
catch (Exception ex)
{
return BadRequest($"Failed to generate QR code: {ex.Message}");
}
}
/// <summary>
/// Retrieves the current password policy for user authentication.
/// </summary>
/// <returns>
/// The current password policy
/// </returns>
[HttpGet("PasswordPolicy")]
public ActionResult<PasswordPolicy> PasswordPolicy() =>
Ok(generalConfig.Value.PasswordPolicy.ConvertToDto());
}

View File

@ -5,9 +5,9 @@
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<Version>1.0-rc4</Version>
<AssemblyVersion>1.0.2.4</AssemblyVersion>
<FileVersion>1.0.2.4</FileVersion>
<Version>1.0-rc5</Version>
<AssemblyVersion>1.0.2.5</AssemblyVersion>
<FileVersion>1.0.2.5</FileVersion>
<AssemblyName>Mirea.Api.Endpoint</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<OutputType>Exe</OutputType>
@ -26,37 +26,40 @@
<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="Cronos" Version="0.8.4" />
<PackageReference Include="EPPlus" Version="7.4.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.11.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.11.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.10">
<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">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="8.0.10">
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.1.2" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.3.0" />
<PackageReference Include="Mirea.Tools.Schedule.WebParser" Version="1.0.4" />
<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.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.10" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="System.CodeDom" Version="8.0.0" />
<PackageReference Include="System.Composition" Version="8.0.0" />
<PackageReference Include="System.Composition.TypedParts" Version="8.0.0" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.1.2" />
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.0.0" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="Z.EntityFramework.Extensions.EFCore" Version="8.103.6" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.11" />
<PackageReference Include="StackExchange.Redis" Version="2.8.22" />
<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" />
</ItemGroup>
<ItemGroup>

View File

@ -76,9 +76,7 @@ public class Program
policy.AllowAnyMethod();
policy.AllowAnyHeader();
policy.AllowCredentials();
#if DEBUG
policy.WithOrigins("http://localhost:4200");
#endif
policy.SetIsOriginAllowed(_ => true);
});
});

148
README.md
View File

@ -11,48 +11,126 @@ The main task is to provide convenient and flexible tools for accessing the sche
The purpose of this project is to provide convenient and flexible tools for obtaining schedule data.
In a situation where existing resources provide limited functionality or an inconvenient interface, this project aims to provide users with a simple and effective tool for accessing information about class schedules.
In a situation where existing resources provide limited functionality or an inconvenient interface, this project aims to
provide users with a simple and effective tool for accessing information about class schedules.
Developing your own API and using your own tools for downloading and processing data allows you to ensure the reliability, flexibility and extensibility of the application functionality.
Developing your own API and using your own tools for downloading and processing data allows you to ensure the
reliability, flexibility and extensibility of the application functionality.
## Features
1. **Flexible API**: The API provides a variety of methods for accessing schedule data. Unlike competitors that provide a limited set of endpoints, this application provides a wider range of functionality, allowing you to get data about groups, campuses, faculties, classrooms and teachers. You can get all the data at once or select specific IDs with the details that are needed.
1. **Flexible API**: The API provides a variety of methods for accessing schedule data. Unlike competitors that provide
a limited set of endpoints, this application provides a wider range of functionality, allowing you to get data about
groups, campuses, faculties, classrooms and teachers. You can get all the data at once or select specific IDs with
the details that are needed.
2. **Database Providers**: The application provides the capability of various database providers.
3. **Using self-written packages**: The project uses two proprietary NuGet packages. One of them is designed for parsing schedules, and the other is for downloading Excel spreadsheets from external sites.
3. **Using self-written packages**: The project uses two proprietary NuGet packages. One of them is designed for parsing
schedules, and the other is for downloading Excel spreadsheets from external sites.
## Project status
The project is under development. Further development will be aimed at expanding the functionality and improving the user experience.
The project is under development. Further development will be aimed at expanding the functionality and improving the
user experience.
# Environment Variables
This table provides information about the environment variables that are used in the application. These variables are stored in the [.env](.env) file.
This table provides information about the environment variables that are used in the application. These variables are
stored in the [.env](.env) file.
In addition to these variables, you also need to fill in a file with settings in json format. The web application provided by this project already has everything necessary to configure the file in the Client-Server communication format via the controller. If you need to get the configuration file otherwise, then you need to refer to the classes that provide configuration-related variables.
In addition to these variables, you also need to fill in a file with settings in json format. The web application
provided by this project already has everything necessary to configure the file in the Client-Server communication
format via the controller. If you need to get the configuration file otherwise, then you need to refer to the classes
that provide configuration-related variables.
Please note that the application will not work correctly if you do not fill in the required variables.
| Variable | Default | Description | Required |
|---------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
| PATH_TO_SAVE | Current folder | The path to save the data. Saving logs (if the full path is not specified), databases (if Sqlite), and other data that should be saved in a place other than where the program is launched. | ✔ |
| SECURITY_SIGNING_TOKEN | ❌ | JWT signature token. This token will be used to create and verify the signature of JWT tokens. The token must be equal to 64 characters. | ✔ |
| SECURITY_ENCRYPTION_TOKEN | | Token for JWT encryption. This token will be used to encrypt and decrypt JWT tokens. The token must be equal to 32 characters. | ✔ |
| SECURITY_LIFE_TIME_RT | 1440 | Time in minutes after which the Refresh Token will become invalid. | ❌ |
| SECURITY_LIFE_TIME_JWT | 15 | Time in minutes after which the JWT token will become invalid. | ❌ |
| SECURITY_LIFE_TIME_1_FA | 15 | Time in minutes after which the token of the first factor will become invalid. | ❌ |
| SECURITY_JWT_ISSUER | ❌ | An identifier that points to the server that created the token. | ✔ |
| SECURITY_JWT_AUDIENCE | ❌ | ID of the audience for which the token is intended. | ✔ |
| SECURITY_HASH_ITERATION | ❌ | The number of iterations used to hash passwords in the Argon2 algorithm. | ✔ |
| SECURITY_HASH_MEMORY | ❌ | The amount of memory used to hash passwords in the Argon2 algorithm. | |
| SECURITY_HASH_PARALLELISM | ❌ | Parallelism determines how many of the memory fragments divided into strips will be used to generate a hash. | ✔ |
| SECURITY_HASH_SIZE | 32 | The size of the output hash generated by the password hashing algorithm. | |
| SECURITY_HASH_TOKEN | ❌ | Additional protection for Argon2. We recommend installing a token so that even if the data is compromised, an attacker cannot brute force a password without a token. | |
| SECURITY_SALT_SIZE | 16 | The size of the salt used to hash passwords. The salt is a random value added to the password before hashing to prevent the use of rainbow hash tables and other attacks. | ❌ |
### General Configuration
| Variable | Default | Description | Required |
|------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
| PATH_TO_SAVE | ./ | The path to save the data. Saving logs, databases (if Sqlite), and other data that should be saved in a different place. REQUIRED if the application is inside the container. | ✔ |
| ACTUAL_SUB_PATH | | The actual sub path to the API. If the specified path ends with "/api", the system will avoid duplicating "api" in the final URL. | ❌ |
| SWAGGER_SUB_PATH | swagger | The sub path to the Swagger documentation. | ❌ |
| INTERNAL_PORT | 8080 | Specify the internal port on which the server will listen. | ❌ |
### Security Configuration
| Variable | Default | Description | Required |
|---------------------------|---------|--------------------------------------------------------------------------------------------------------------------------------|----------|
| SECURITY_SIGNING_TOKEN | | JWT signature token. This token will be used to create and verify the signature of JWT tokens. Must be equal to 64 characters. | |
| SECURITY_ENCRYPTION_TOKEN | | Token for JWT encryption. This token will be used to encrypt and decrypt JWT tokens. Must be equal to 32 characters. | |
| SECURITY_LIFE_TIME_RT | 1440 | Time in minutes, which indicates after which time the Refresh Token will become invalid. | ❌ |
| SECURITY_LIFE_TIME_JWT | 15 | The time in minutes for the JWT to be valid. | ❌ |
| SECURITY_LIFE_TIME_1_FA | 15 | Time in minutes after which the token of the first factor will become invalid. | ❌ |
| SECURITY_JWT_ISSUER | | An identifier that points to the server that created the token. | ✔ |
| SECURITY_JWT_AUDIENCE | | ID of the audience for which the token is intended. | ✔ |
### Hashing Configuration
| Variable | Default | Description | Required |
|---------------------------|---------|-------------------------------------------------------------------------------------------------------------------|----------|
| SECURITY_HASH_ITERATION | | The number of iterations used to hash passwords in the Argon2 algorithm. At least 10 is recommended for security. | ✔ |
| SECURITY_HASH_MEMORY | 65536 | The amount of memory used to hash passwords in the Argon2 algorithm (in KB). | ✔ |
| SECURITY_HASH_PARALLELISM | | Parallelism determines how many of the memory fragments divided into strips will be used to generate a hash. | ✔ |
| SECURITY_HASH_SIZE | 32 | The size of the output hash generated by the password hashing algorithm. | ✔ |
| SECURITY_HASH_TOKEN | | Additional protection for Argon2. Recommended to install a token for added security. | ❌ |
| SECURITY_SALT_SIZE | 16 | The size of the salt used to hash passwords. | ✔ |
### OAuth2 Configuration
To set up the `redirect URL` when registering and logging in using OAuth 2, use the following format:
```
"{schema}://{domain}{portString}{ACTUAL_SUB_PATH}/api/v1/Auth/OAuth2"
```
** 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 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.
**Examples:**
- If you have `ACTUAL_SUB_PATH = my_subpath/api`, then the `redirect URL` will be:
- `https://mydomain.com/my_subpath/api/v1/Auth/OAuth2`
- If your application is on a local server and uses a non-standard port:
- `http://192.168.1.5:8080/my_subpath/api/v1/Auth/OAuth2 `
**Important:**
- If your application is not located behind a proxy server, then the parameter `{ACTUAL_SUB_PATH}` does not need to be
specified.
#### Google
| Variable | Default | Description | Required |
|----------------------|---------|-----------------------------------------------------------------------------------------------------------------------------------------|----------|
| GOOGLE_CLIENT_ID | | The client ID provided by Google when you register your application for OAuth. It is necessary for enabling Google login functionality. | ✔ |
| GOOGLE_CLIENT_SECRET | | The client secret provided by Google, used alongside the client ID to authenticate your application. | ✔ |
#### Yandex
| Variable | Default | Description | Required |
|----------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------|----------|
| YANDEX_CLIENT_ID | | The client ID provided by Yandex when you register your application for OAuth. It is required for enabling Yandex login functionality. | ✔ |
| YANDEX_CLIENT_SECRET | | The client secret provided by Yandex, used alongside the client ID to authenticate your application. | ✔ |
#### MailRu
| Variable | Default | Description | Required |
|----------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
| MAILRU_CLIENT_ID | | The client ID provided by MailRu (Mail.ru Group) when you register your application for OAuth. It is necessary for enabling MailRu login functionality. | ✔ |
| MAILRU_CLIENT_SECRET | | The client secret provided by MailRu, used alongside the client ID to authenticate your application. | ✔ |
# Installation
If you want to make a fork of this project or place the Backend application on your hosting yourself, then follow the instructions below.
If you want to make a fork of this project or place the Backend application on your hosting yourself, then follow the
instructions below.
1. [Docker Installation](#docker-installation)
2. [Docker Self Build](#docker-self-build)
@ -86,11 +164,14 @@ Using the `--name` option, you can specify your container name, for example: `--
With the `-p` option, you can specify the port you need: `-p 80:8080`.
It is necessary to tell the application exactly where to save the data so that it does not disappear when the container is deleted.
It is necessary to tell the application exactly where to save the data so that it does not disappear when the container
is deleted.
To do this, replace the `-v` option, where you need to specify the path to the data on the host first, and then using `:` specify the path inside the container. `-v /nas/mirea/backend:/myfolder`.
To do this, replace the `-v` option, where you need to specify the path to the data on the host first, and then using
`:` specify the path inside the container. `-v /nas/mirea/backend:/myfolder`.
At the same time, do not forget to replace inside [.env](.env) `PATH_TO_SAVE` with what you specify in the `-v` option. In our case, it will be `PATH_TO_SAVE=/myfolder`.
At the same time, do not forget to replace inside [.env](.env) `PATH_TO_SAVE` with what you specify in the `-v` option.
In our case, it will be `PATH_TO_SAVE=/myfolder`.
That's it, the container is running!
@ -106,7 +187,8 @@ docker build -t my-name/mirea-backend:latest .
Where `-t` indicates the name and version of the image. You can specify their `your-name/image-name:version`.
Now the image is ready. To launch the container, refer to [Docker Installation](#docker-installation), do not forget to specify the name of the image that you have built.
Now the image is ready. To launch the container, refer to [Docker Installation](#docker-installation), do not forget to
specify the name of the image that you have built.
## Manual Installation
@ -123,11 +205,14 @@ To install using a pre-built application, follow these steps:
### Install ASP.NET
Installation ASP.NET it depends on the specific platform.
Go to [Microsoft website](https://dotnet.microsoft.com/download/dotnet/8.0 ) and find your platform. Follow the installation instructions.
Go to [Microsoft website](https://dotnet.microsoft.com/download/dotnet/8.0 ) and find your platform. Follow the
installation instructions.
### Download Package
The latest versions of the packages can be found in [releases](https://git.winsomnia.net/Winsomnia/MireaBackend/releases ). If there is no build for your platform, go [to the Self Build section](#self-build).
The latest versions of the packages can be found
in [releases](https://git.winsomnia.net/Winsomnia/MireaBackend/releases ). If there is no build for your platform,
go [to the Self Build section](#self-build).
### Run
@ -160,7 +245,8 @@ To build your own version of the program, follow these steps:
### Install NET SDK
Installation.The NET SDK depends on the specific platform.
Go to [Microsoft website](https://dotnet.microsoft.com/download/dotnet/8.0 ) and find your platform. Follow the installation instructions.
Go to [Microsoft website](https://dotnet.microsoft.com/download/dotnet/8.0 ) and find your platform. Follow the
installation instructions.
### Clone The Repository

View File

@ -1,6 +1,6 @@
using System;
namespace Mirea.Api.Security.Common.Domain;
namespace Mirea.Api.Security.Common.Domain.Caching;
internal class AuthToken
{

View File

@ -1,4 +1,4 @@
namespace Mirea.Api.Security.Common.Domain;
namespace Mirea.Api.Security.Common.Domain.Caching;
internal class FirstAuthToken
{
@ -17,6 +17,6 @@ internal class FirstAuthToken
public string Ip { get; set; } = null!;
public string Fingerprint { get; set; } = null!;
public required string UserId { get; set; }
public required SecondFactor SecondFactor { get; set; }
public required TwoFactorAuthenticator TwoFactorAuthenticator { get; set; }
public string? Secret { get; set; }
}

View File

@ -0,0 +1,13 @@
using System;
namespace Mirea.Api.Security.Common.Domain.OAuth2;
internal struct OAuthProviderUrisData
{
public string RedirectUrl { get; init; }
public string TokenUrl { get; init; }
public string UserInfoUrl { get; init; }
public string AuthHeader { get; init; }
public string Scope { get; init; }
public Type UserInfoType { get; init; }
}

View File

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

View File

@ -0,0 +1,38 @@
using Mirea.Api.Security.Common.Interfaces;
using System.Text.Json.Serialization;
namespace Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
internal class GoogleUserInfo : IUserInfo
{
[JsonPropertyName("id")]
public required string Id { get; set; }
[JsonPropertyName("email")]
public required string Email { get; set; }
[JsonPropertyName("given_name")]
public required string GivenName { get; set; }
[JsonPropertyName("family_name")]
public required string FamilyName { get; set; }
[JsonPropertyName("name")]
public required string Name { get; set; }
[JsonPropertyName("picture")]
public required string Picture { get; set; }
[JsonPropertyName("verified_email")]
public bool? VerifiedEmail { get; set; }
public OAuthUser MapToInternalUser() =>
new()
{
Id = Id,
Email = VerifiedEmail.HasValue && VerifiedEmail.Value ? Email : null,
FirstName = GivenName,
LastName = FamilyName,
IconUri = Picture
};
}

View File

@ -0,0 +1,36 @@
using Mirea.Api.Security.Common.Interfaces;
using System.Text.Json.Serialization;
namespace Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
internal class MailRuUserInfo : IUserInfo
{
[JsonPropertyName("id")]
public required string Id { get; set; }
[JsonPropertyName("email")]
public required string Email { get; set; }
[JsonPropertyName("first_name")]
public required string FirstName { get; set; }
[JsonPropertyName("last_name")]
public required string LastName { get; set; }
[JsonPropertyName("nickname")]
public required string Username { get; set; }
[JsonPropertyName("image")]
public string? Image { get; set; }
public OAuthUser MapToInternalUser() =>
new()
{
Id = Id,
Email = Email,
FirstName = FirstName,
LastName = LastName,
Username = Username,
IconUri = Image
};
}

View File

@ -0,0 +1,41 @@
using Mirea.Api.Security.Common.Interfaces;
using System.Text.Json.Serialization;
namespace Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
internal class YandexUserInfo : IUserInfo
{
[JsonPropertyName("id")]
public required string Id { get; set; }
[JsonPropertyName("login")]
public required string Login { get; set; }
[JsonPropertyName("default_email")]
public required string DefaultEmail { get; set; }
[JsonPropertyName("first_name")]
public string? FirstName { get; set; }
[JsonPropertyName("last_name")]
public string? LastName { get; set; }
[JsonPropertyName("is_avatar_empty")]
public bool IsAvatarEmpty { get; set; }
[JsonPropertyName("default_avatar_id")]
public string? DefaultAvatarId { get; set; }
public OAuthUser MapToInternalUser() =>
new()
{
Id = Id,
Email = DefaultEmail,
FirstName = FirstName,
LastName = LastName,
IconUri =
IsAvatarEmpty ? null : $"https://avatars.yandex.net/get-yapic/{DefaultAvatarId}/islands-retina-50",
Username = Login
};
}

View File

@ -0,0 +1,8 @@
namespace Mirea.Api.Security.Common.Domain;
public enum OAuthProvider
{
Google,
Yandex,
MailRu
}

View File

@ -0,0 +1,11 @@
namespace Mirea.Api.Security.Common.Domain;
public class OAuthUser
{
public required string Id { get; set; }
public string? Username { get; set; }
public string? Email { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? IconUri { get; set; }
}

View File

@ -0,0 +1,15 @@
namespace Mirea.Api.Security.Common.Domain;
public class PasswordPolicy(
int minimumLength = 8,
bool requireLetter = true,
bool requireLettersDifferentCase = true,
bool requireDigit = true,
bool requireSpecialCharacter = true)
{
public int MinimumLength { get; set; } = minimumLength;
public bool RequireLetter { get; set; } = requireLetter;
public bool RequireLettersDifferentCase { get; set; } = requireLettersDifferentCase;
public bool RequireDigit { get; set; } = requireDigit;
public bool RequireSpecialCharacter { get; set; } = requireSpecialCharacter;
}

View File

@ -22,7 +22,7 @@ internal class RequestContextInfo
if (string.IsNullOrEmpty(fingerprint))
{
fingerprint = Guid.NewGuid().ToString().Replace("-", "") + GeneratorKey.GenerateString(32);
cookieOptions.SetCookie(context, CookieNames.FingerprintToken, fingerprint);
cookieOptions.SetCookie(context, CookieNames.FingerprintToken, fingerprint, DateTimeOffset.UtcNow.AddYears(10));
}
UserAgent = userAgent;

View File

@ -0,0 +1,7 @@
namespace Mirea.Api.Security.Common.Domain;
public enum TwoFactorAuthenticator
{
None,
Totp
}

View File

@ -1,18 +1,15 @@
namespace Mirea.Api.Security.Common.Domain;
using System.Collections.Generic;
public enum SecondFactor
{
None,
Totp
}
namespace Mirea.Api.Security.Common.Domain;
public class User
{
public required int Id { get; set; }
public required string Id { get; set; }
public required string Username { get; set; }
public required string Email { get; set; }
public required string PasswordHash { get; set; }
public required string Salt { get; set; }
public required SecondFactor SecondFactor { get; set; }
public required TwoFactorAuthenticator TwoFactorAuthenticator { get; set; }
public string? SecondFactorToken { get; set; }
public Dictionary<OAuthProvider, OAuthUser>? OAuthProviders { get; set; }
}

View File

@ -0,0 +1,8 @@
using Mirea.Api.Security.Common.Domain;
namespace Mirea.Api.Security.Common.Interfaces;
internal interface IUserInfo
{
OAuthUser MapToInternalUser();
}

View File

@ -1,9 +1,11 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Interfaces;
using Mirea.Api.Security.Services;
using System;
using System.Collections.Generic;
namespace Mirea.Api.Security;
@ -45,6 +47,22 @@ public static class DependencyInjection
};
});
var providers = new Dictionary<OAuthProvider, (string ClientId, string Secret)>();
foreach (var provider in Enum.GetValues<OAuthProvider>())
{
var providerName = Enum.GetName(provider)!.ToUpper();
var clientId = configuration[$"{providerName}_CLIENT_ID"];
var secret = configuration[$"{providerName}_CLIENT_SECRET"];
if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(secret))
continue;
providers.Add(provider, (clientId, secret));
}
services.AddSingleton(provider => new OAuthService(provider.GetRequiredService<ILogger<OAuthService>>(), providers, configuration["SECURITY_ENCRYPTION_TOKEN"]!));
return services;
}
}

View File

@ -5,9 +5,9 @@
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<Version>1.1.0</Version>
<AssemblyVersion>1.1.3.0</AssemblyVersion>
<FileVersion>1.1.3.0</FileVersion>
<Version>1.1.1</Version>
<AssemblyVersion>1.1.3.1</AssemblyVersion>
<FileVersion>1.1.3.1</FileVersion>
<AssemblyName>Mirea.Api.Security</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<OutputType>Library</OutputType>
@ -15,9 +15,10 @@
<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<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="Otp.NET" Version="1.4.0" />
<PackageReference Include="System.Memory" Version="4.6.0" />
</ItemGroup>
</Project>

View File

@ -2,6 +2,7 @@
using Microsoft.Extensions.Logging;
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 System;
using System.Security;
@ -31,6 +32,18 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
slidingExpiration: Lifetime,
cancellationToken: cancellation);
private Task CreateFirstAuthTokenToCache(User data, RequestContextInfo requestContext, CancellationToken cancellation) =>
cache.SetAsync(
GetFirstAuthCacheKey(requestContext.Fingerprint),
JsonSerializer.SerializeToUtf8Bytes(new FirstAuthToken(requestContext)
{
UserId = data.Id,
Secret = data.SecondFactorToken,
TwoFactorAuthenticator = data.TwoFactorAuthenticator
}),
slidingExpiration: LifetimeFirstAuth,
cancellationToken: cancellation);
private Task RevokeAccessToken(string token) =>
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
@ -49,23 +62,23 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
if (countFailedLogin > 5)
{
logger.LogWarning(
"Multiple failed login attempts detected for user ID {UserId} from IP {UserIp}. Attempt: #{AttemptNumber}. Possible account compromise.",
"Multiple unsuccessful login attempts for user ID {UserId} from IP {UserIp}. Attempt count: {AttemptNumber}.",
user.Id,
requestContext.Ip,
countFailedLogin);
throw new SecurityException($"There are many incorrect attempts to access the account. Try again after {(int)cacheSaveTime.TotalMinutes} minutes.");
throw new SecurityException("Too many unsuccessful login attempts. Please try again later.");
}
logger.LogInformation(
"Failed login attempt for user ID {UserId} from IP {UserIp} with User-Agent {UserAgent} and Fingerprint {Fingerprint} Attempt: #{AttemptNumber}.",
"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);
throw new SecurityException("Invalid username/email or password");
throw new SecurityException("Authentication failed. Please check your credentials.");
}
private async Task GenerateAuthTokensAsync(CookieOptionsParameters cookieOptions, HttpContext context, RequestContextInfo requestContext, string userId, CancellationToken cancellation = default)
@ -82,67 +95,77 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
};
await SetAuthTokenDataToCache(authToken, cancellation);
cookieOptions.SetCookie(context, CookieNames.AccessToken, token, expireIn);
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
logger.LogInformation(
"Successful login attempt for user ID {UserId} from IP {UserIp} with User-Agent {UserAgent} and Fingerprint {Fingerprint}.",
"Login successful for user ID {UserId}. IP: {UserIp}, User-Agent: {UserAgent}, Fingerprint: {Fingerprint}.",
authToken.UserId,
authToken.Ip,
authToken.UserAgent,
authToken.Fingerprint);
}
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, string code, CancellationToken cancellation = default)
public async Task<TwoFactorAuthenticator> LoginOAuthAsync(CookieOptionsParameters cookieOptions, HttpContext context, User user, OAuthProvider provider, CancellationToken cancellation = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellation)
?? throw new SecurityException("The session time has expired");
switch (firstTokenAuth.SecondFactor)
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
{
case SecondFactor.Totp:
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);
if (firstTokenAuth == null || authenticator != firstTokenAuth.TwoFactorAuthenticator)
throw new SecurityException("Session expired. Please log in again.");
switch (firstTokenAuth.TwoFactorAuthenticator)
{
case TwoFactorAuthenticator.Totp:
{
if (string.IsNullOrEmpty(firstTokenAuth.Secret))
throw new InvalidOperationException("The user's secrets for data processing were not transferred.");
throw new InvalidOperationException("Required authentication data is missing.");
var totp = new TotpService(firstTokenAuth.Secret);
if (!totp.VerifyToken(code))
throw new SecurityException("The entered code is incorrect.");
throw new SecurityException("Invalid verification code. Please try again.");
}
break;
default:
throw new InvalidOperationException("The system failed to understand the authorization method.");
throw new InvalidOperationException("Unsupported authorization method.");
}
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellation);
return true;
}
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, User user, HttpContext context, string password, CancellationToken cancellation = default)
public async Task<TwoFactorAuthenticator> LoginAsync(CookieOptionsParameters cookieOptions, User user, HttpContext context, string password, CancellationToken cancellation = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
await VerifyUserOrThrowError(requestContext, user, password, cancellation);
if (user.SecondFactor == SecondFactor.None)
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
{
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id.ToString(), cancellation);
return true;
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation);
return TwoFactorAuthenticator.None;
}
var firstAuthToken = new FirstAuthToken(requestContext)
{
UserId = user.Id.ToString(),
Secret = user.SecondFactorToken,
SecondFactor = user.SecondFactor
};
await CreateFirstAuthTokenToCache(user, requestContext, cancellation);
await cache.SetAsync(GetFirstAuthCacheKey(requestContext.Fingerprint), firstAuthToken, absoluteExpirationRelativeToNow: LifetimeFirstAuth, cancellationToken: cancellation);
return false;
return user.TwoFactorAuthenticator;
}
public async Task RefreshTokenAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default)
@ -160,7 +183,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
logger.LogWarning("Token validation failed for user ID {UserId}. Invalid token used from IP {UserIp} with User-Agent {UserAgent} and Fingerprint {Fingerprint}. Possible account compromise. Reason: {Reason}.",
logger.LogWarning("Token validation failed for user ID {UserId}. IP: {UserIp}, User-Agent: {UserAgent}, Fingerprint: {Fingerprint}. Reason: {Reason}.",
authToken.UserId,
authToken.Ip,
authToken.UserAgent,
@ -181,13 +204,17 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
authToken.RefreshToken = newRefreshToken;
await SetAuthTokenDataToCache(authToken, cancellation);
cookieOptions.SetCookie(context, CookieNames.AccessToken, token, expireIn);
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)
{
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);
if (authTokenStruct == null)
@ -195,7 +222,5 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
await RevokeAccessToken(authTokenStruct.AccessToken);
await cache.RemoveAsync(requestContext.Fingerprint, cancellation);
cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
}
}

View File

@ -25,6 +25,10 @@ public static class GeneratorKey
.ToArray());
}
public static string GenerateAlphaNumericBase32Compatible(int size, string? excludes = null,
string? includes = null) =>
GenerateAlphaNumeric(size, excludes + "0189", includes);
public static ReadOnlySpan<byte> GenerateBytes(int size)
{
var key = new byte[size];

View File

@ -0,0 +1,179 @@
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.Interfaces;
using System;
using System.Collections.Generic;
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;
namespace Mirea.Api.Security.Services;
public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider, (string ClientId, string Secret)> providers, string secretKey)
{
private static readonly Dictionary<OAuthProvider, OAuthProviderUrisData> ProviderData = new()
{
[OAuthProvider.Google] = new OAuthProviderUrisData
{
RedirectUrl = "https://accounts.google.com/o/oauth2/v2/auth",
TokenUrl = "https://oauth2.googleapis.com/token",
UserInfoUrl = "https://www.googleapis.com/oauth2/v2/userinfo",
Scope = "openid email profile",
AuthHeader = "Bearer",
UserInfoType = typeof(GoogleUserInfo)
},
[OAuthProvider.Yandex] = new OAuthProviderUrisData
{
RedirectUrl = "https://oauth.yandex.ru/authorize",
TokenUrl = "https://oauth.yandex.ru/token",
UserInfoUrl = "https://login.yandex.ru/info?format=json",
Scope = "login:email login:info login:avatar",
AuthHeader = "OAuth",
UserInfoType = typeof(YandexUserInfo)
},
[OAuthProvider.MailRu] = new OAuthProviderUrisData
{
RedirectUrl = "https://oauth.mail.ru/login",
TokenUrl = "https://oauth.mail.ru/token",
UserInfoUrl = "https://oauth.mail.ru/userinfo",
AuthHeader = "",
Scope = "",
UserInfoType = typeof(MailRuUserInfo)
}
};
private static async Task<OAuthTokenResponse?> ExchangeCodeForTokensAsync(string requestUri, string redirectUrl, string code, string clientId, string secret, CancellationToken cancellation)
{
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "code", code },
{ "client_id", clientId },
{ "client_secret", secret },
{ "redirect_uri", redirectUrl},
{ "grant_type", "authorization_code" }
})
};
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);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(data);
return JsonSerializer.Deserialize<OAuthTokenResponse>(data);
}
private static async Task<OAuthUser?> GetUserProfileAsync(string requestUri, string authHeader, string accessToken, OAuthProvider provider, CancellationToken cancellation)
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
if (string.IsNullOrEmpty(authHeader))
request.RequestUri = new Uri(request.RequestUri?.AbsoluteUri + "?access_token=" + accessToken);
else
request.Headers.Authorization = new AuthenticationHeaderValue(authHeader, accessToken);
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);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(data);
var userInfo = JsonSerializer.Deserialize(data, ProviderData[provider].UserInfoType) as IUserInfo;
return userInfo?.MapToInternalUser();
}
private static string GetHmacString(RequestContextInfo contextInfo, string secretKey)
{
var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey));
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)
{
var providerData = providers[provider];
var redirectUrl = $"?client_id={providerData.ClientId}" +
"&response_type=code" +
$"&redirect_uri={redirectUri}" +
$"&scope={ProviderData[provider].Scope}" +
$"&state={GetHmacString(new RequestContextInfo(context, cookieOptions), secretKey)}_{Enum.GetName(provider)}";
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 async Task<(OAuthProvider provider, OAuthUser User)> LoginOAuth(HttpContext context, CookieOptionsParameters cookieOptions, string redirectUrl, string code, string state, CancellationToken cancellation = 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))
{
logger.LogWarning("Failed to parse OAuth provider from state: {State}", state);
throw new InvalidOperationException("Invalid authorization request.");
}
var secretStateData = string.Join("_", partsState.SkipLast(1));
var secretData = GetHmacString(new RequestContextInfo(context, cookieOptions), secretKey);
if (secretData != secretStateData)
{
logger.LogWarning("Fingerprint mismatch. Possible CSRF attack detected.");
throw new SecurityException("Suspicious activity detected. Please try again.");
}
OAuthTokenResponse? accessToken = null;
try
{
accessToken = await ExchangeCodeForTokensAsync(currentProviderStruct.TokenUrl, redirectUrl, code, providerInfo.ClientId, providerInfo.Secret, cancellation);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to exchange authorization code for tokens with provider {Provider}", provider);
}
if (accessToken == null)
throw new SecurityException("Unable to complete authorization with the provider. Please try again later.");
OAuthUser? result = null;
try
{
result = await GetUserProfileAsync(currentProviderStruct.UserInfoUrl, currentProviderStruct.AuthHeader, accessToken.AccessToken, provider, cancellation);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to retrieve user information from provider {Provider}", provider);
}
if (result == null)
throw new SecurityException("Unable to retrieve user information. Please check the details and try again.");
return (provider, result);
}
}

View File

@ -1,11 +1,10 @@
using Konscious.Security.Cryptography;
using System;
using System.Text;
using System.Text.RegularExpressions;
namespace Mirea.Api.Security.Services;
public partial class PasswordHashService
public class PasswordHashService
{
public int SaltSize { private get; init; }
public int HashSize { private get; init; }
@ -54,15 +53,4 @@ public partial class PasswordHashService
public bool VerifyPassword(string password, string saltBase64, string hashBase64) =>
VerifyPassword(password, Convert.FromBase64String(saltBase64), Convert.FromBase64String(hashBase64));
public static bool HasPasswordInPolicySecurity(string password) =>
password.Length >= 8 &&
PasswordExistSpecialSymbol().IsMatch(password) &&
PasswordExistUpperLetter().IsMatch(password);
[GeneratedRegex("[A-Z]+")]
private static partial Regex PasswordExistUpperLetter();
[GeneratedRegex("[!@#$%^&*]+")]
private static partial Regex PasswordExistSpecialSymbol();
}

View File

@ -0,0 +1,40 @@
using Mirea.Api.Security.Common.Domain;
using System.Linq;
using System.Security;
namespace Mirea.Api.Security.Services;
public class PasswordPolicyService(PasswordPolicy policy)
{
public void ValidatePasswordOrThrow(string password)
{
if (password.Length < policy.MinimumLength)
throw new SecurityException($"Password must be at least {policy.MinimumLength} characters long.");
if (policy.RequireLetter && !password.Any(char.IsLetter))
throw new SecurityException("Password must contain at least one letter.");
if (policy.RequireLettersDifferentCase && !password.Any(char.IsLower) && !password.Any(char.IsUpper))
throw new SecurityException("Password must contain at least one lowercase and uppercase letter.");
if (policy.RequireDigit && !password.Any(char.IsDigit))
throw new SecurityException("Password must contain at least one digit.");
if (policy.RequireSpecialCharacter && password.All(char.IsLetterOrDigit))
throw new SecurityException("Password must contain at least one special character.");
}
public bool TryValidatePassword(string password)
{
try
{
ValidatePasswordOrThrow(password);
}
catch
{
return false;
}
return true;
}
}

View File

@ -5,18 +5,18 @@
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<Version>1.0.2</Version>
<AssemblyVersion>1.0.3.2</AssemblyVersion>
<FileVersion>1.0.3.2</FileVersion>
<Version>1.0.3</Version>
<AssemblyVersion>1.0.3.3</AssemblyVersion>
<FileVersion>1.0.3.3</FileVersion>
<AssemblyName>Mirea.Api.DataAccess.Application</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.10.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
<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.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
</ItemGroup>
<ItemGroup>

View File

@ -5,9 +5,9 @@
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<Version>1.0.2</Version>
<AssemblyVersion>1.0.3.2</AssemblyVersion>
<FileVersion>1.0.3.2</FileVersion>
<Version>1.0.3</Version>
<AssemblyVersion>1.0.3.3</AssemblyVersion>
<FileVersion>1.0.3.3</FileVersion>
<AssemblyName>Mirea.Api.DataAccess.Persistence</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
</PropertyGroup>
@ -16,11 +16,14 @@
<PackageReference Include="AspNetCore.HealthChecks.MySql" Version="8.0.1" />
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Sqlite" Version="8.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.10" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.11" />
<PackageReference Include="MySqlConnector" Version="2.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
</ItemGroup>
<ItemGroup>