Compare commits
35 Commits
eb272baa38
...
5cc54eac44
Author | SHA1 | Date | |
---|---|---|---|
5cc54eac44 | |||
e9ff1cabe8 | |||
fd578aa61e | |||
cff42d0a31 | |||
8250957b85 | |||
39208037f0 | |||
5e072d88c2 | |||
25eddbe776 | |||
74ba4e901a | |||
e760ddae0a | |||
598ebabc5c | |||
08aeb7ea3c | |||
182235c4cd | |||
5437623a20 | |||
2c09122971 | |||
503f5792fb | |||
95627003e5 | |||
a96073d44d | |||
5f36e0f75b | |||
e977de3e4f | |||
65d928ec2d | |||
713bbfa16f | |||
6b5eda7756 | |||
dbd9e1a070 | |||
0dda336de1 | |||
727f5c276e | |||
db70e4dd96 | |||
6831d9c708 | |||
1b24954c3e | |||
c5ba1cfcca | |||
3811d879ab | |||
61dc0a8bc4 | |||
b3b00aa9e1 | |||
6c9af942f4 | |||
23f74b3bdf |
48
.env
48
.env
@ -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=
|
||||
|
17
ApiDto/Common/CacheType.cs
Normal file
17
ApiDto/Common/CacheType.cs
Normal 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
|
||||
}
|
22
ApiDto/Common/DatabaseType.cs
Normal file
22
ApiDto/Common/DatabaseType.cs
Normal 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
|
||||
}
|
22
ApiDto/Common/OAuthProvider.cs
Normal file
22
ApiDto/Common/OAuthProvider.cs
Normal 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
|
||||
}
|
32
ApiDto/Common/PasswordPolicy.cs
Normal file
32
ApiDto/Common/PasswordPolicy.cs
Normal 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; }
|
||||
}
|
@ -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.
|
@ -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; }
|
||||
}
|
||||
|
19
ApiDto/Requests/TwoFactorAuthRequest.cs
Normal file
19
ApiDto/Requests/TwoFactorAuthRequest.cs
Normal 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; }
|
||||
}
|
27
ApiDto/Responses/AvailableOAuthProvidersResponse.cs
Normal file
27
ApiDto/Responses/AvailableOAuthProvidersResponse.cs
Normal 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; }
|
||||
}
|
29
ApiDto/Responses/Configuration/CacheResponse.cs
Normal file
29
ApiDto/Responses/Configuration/CacheResponse.cs
Normal 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; }
|
||||
}
|
49
ApiDto/Responses/Configuration/DatabaseResponse.cs
Normal file
49
ApiDto/Responses/Configuration/DatabaseResponse.cs
Normal 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; }
|
||||
}
|
17
ApiDto/Responses/TotpKeyResponse.cs
Normal file
17
ApiDto/Responses/TotpKeyResponse.cs
Normal 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; }
|
||||
}
|
35
ApiDto/Responses/UserResponse.cs
Normal file
35
ApiDto/Responses/UserResponse.cs
Normal 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; }
|
||||
}
|
@ -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;
|
27
Endpoint/Common/MapperDto/AvailableProvidersConverter.cs
Normal file
27
Endpoint/Common/MapperDto/AvailableProvidersConverter.cs
Normal 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();
|
||||
}
|
@ -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
|
||||
{
|
23
Endpoint/Common/MapperDto/PasswordPolicyConverter.cs
Normal file
23
Endpoint/Common/MapperDto/PasswordPolicyConverter.cs
Normal 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
|
||||
};
|
||||
}
|
@ -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)
|
||||
};
|
||||
}
|
@ -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, "")
|
||||
]),
|
||||
|
@ -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('/')}";
|
||||
}
|
||||
}
|
@ -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 =>
|
||||
|
@ -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>();
|
||||
|
@ -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));
|
||||
|
@ -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()
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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()
|
||||
};
|
||||
|
||||
|
@ -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]
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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;
|
||||
|
84
Endpoint/Controllers/V1/SecurityController.cs
Normal file
84
Endpoint/Controllers/V1/SecurityController.cs
Normal 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());
|
||||
}
|
@ -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>
|
||||
|
@ -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
148
README.md
@ -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
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace Mirea.Api.Security.Common.Domain;
|
||||
namespace Mirea.Api.Security.Common.Domain.Caching;
|
||||
|
||||
internal class AuthToken
|
||||
{
|
@ -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; }
|
||||
}
|
13
Security/Common/Domain/OAuth2/OAuthProviderUrisData.cs
Normal file
13
Security/Common/Domain/OAuth2/OAuthProviderUrisData.cs
Normal 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; }
|
||||
}
|
12
Security/Common/Domain/OAuth2/OAuthTokenResponse.cs
Normal file
12
Security/Common/Domain/OAuth2/OAuthTokenResponse.cs
Normal 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; }
|
||||
}
|
38
Security/Common/Domain/OAuth2/UserInfo/GoogleUserInfo.cs
Normal file
38
Security/Common/Domain/OAuth2/UserInfo/GoogleUserInfo.cs
Normal 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
|
||||
};
|
||||
}
|
36
Security/Common/Domain/OAuth2/UserInfo/MailRuUserInfo.cs
Normal file
36
Security/Common/Domain/OAuth2/UserInfo/MailRuUserInfo.cs
Normal 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
|
||||
};
|
||||
}
|
41
Security/Common/Domain/OAuth2/UserInfo/YandexUserInfo.cs
Normal file
41
Security/Common/Domain/OAuth2/UserInfo/YandexUserInfo.cs
Normal 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
|
||||
};
|
||||
}
|
8
Security/Common/Domain/OAuthProvider.cs
Normal file
8
Security/Common/Domain/OAuthProvider.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace Mirea.Api.Security.Common.Domain;
|
||||
|
||||
public enum OAuthProvider
|
||||
{
|
||||
Google,
|
||||
Yandex,
|
||||
MailRu
|
||||
}
|
11
Security/Common/Domain/OAuthUser.cs
Normal file
11
Security/Common/Domain/OAuthUser.cs
Normal 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; }
|
||||
}
|
15
Security/Common/Domain/PasswordPolicy.cs
Normal file
15
Security/Common/Domain/PasswordPolicy.cs
Normal 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;
|
||||
}
|
@ -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;
|
||||
|
7
Security/Common/Domain/TwoFactorAuthenticator.cs
Normal file
7
Security/Common/Domain/TwoFactorAuthenticator.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Mirea.Api.Security.Common.Domain;
|
||||
|
||||
public enum TwoFactorAuthenticator
|
||||
{
|
||||
None,
|
||||
Totp
|
||||
}
|
@ -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; }
|
||||
}
|
8
Security/Common/Interfaces/IUserInfo.cs
Normal file
8
Security/Common/Interfaces/IUserInfo.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using Mirea.Api.Security.Common.Domain;
|
||||
|
||||
namespace Mirea.Api.Security.Common.Interfaces;
|
||||
|
||||
internal interface IUserInfo
|
||||
{
|
||||
OAuthUser MapToInternalUser();
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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];
|
||||
|
179
Security/Services/OAuthService.cs
Normal file
179
Security/Services/OAuthService.cs
Normal 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);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
40
Security/Services/PasswordPolicyService.cs
Normal file
40
Security/Services/PasswordPolicyService.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user