Compare commits

...

13 Commits

Author SHA1 Message Date
07111b9b61 sec: do not return the error text to the user
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m40s
.NET Test Pipeline / build-and-test (push) Successful in 1m18s
2024-12-26 16:40:30 +03:00
538f1d67c8 fix: change the link to the error type 2024-12-26 16:39:29 +03:00
233458ed89 refactor: add standard traceId 2024-12-26 16:38:53 +03:00
7f87b4d856 style: add space between point and provider 2024-12-26 16:38:13 +03:00
0c6d1c9bfb refactor: compact two factor auth 2024-12-26 16:16:33 +03:00
516ba5bb8e feat: add a token handler 2024-12-26 16:14:55 +03:00
9d5007ef3a refactor: add user converter 2024-12-26 16:14:28 +03:00
c75ac60b0b sec: add verification for OAuth authorization 2024-12-26 15:47:38 +03:00
5b7412f20f feat: return the provider 2024-12-26 15:46:55 +03:00
c4a4478b8c refactor: standardize the order of arguments 2024-12-26 15:46:30 +03:00
05166188be feat: add a method for getting info about a token 2024-12-26 14:32:28 +03:00
157708d00f feat: store the result at each stage 2024-12-26 14:18:12 +03:00
36026b3afb refactor: distribute the domain folder 2024-12-26 13:38:43 +03:00
26 changed files with 324 additions and 93 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -34,10 +34,10 @@ public class CustomExceptionHandlerMiddleware(RequestDelegate next, ILogger<Cust
var problemDetails = new ProblemDetails var problemDetails = new ProblemDetails
{ {
Type = "https://tools.ietf.org/html/rfc9110#section-15.6", Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
Title = "An unexpected error occurred.", Title = "An unexpected error occurred.",
Status = StatusCodes.Status500InternalServerError, Status = StatusCodes.Status500InternalServerError,
Detail = exception.Message, Detail = "Please provide this traceId to the administrator for further investigation.",
Extensions = new Dictionary<string, object?>() Extensions = new Dictionary<string, object?>()
{ {
{ "traceId", traceId } { "traceId", traceId }

View File

@ -1,5 +1,6 @@
using Mirea.Api.Endpoint.Common.Services; using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Security.Common.Domain; using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Model;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;

View File

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

View File

@ -18,7 +18,7 @@ using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model; using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings; using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Configuration.Validation.Validators; using Mirea.Api.Endpoint.Configuration.Validation.Validators;
using Mirea.Api.Security.Common.Domain; using Mirea.Api.Security.Common.Model;
using Mirea.Api.Security.Services; using Mirea.Api.Security.Services;
using MySqlConnector; using MySqlConnector;
using Npgsql; using Npgsql;
@ -32,6 +32,7 @@ using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Security; using System.Security;
using System.Security.Cryptography; using System.Security.Cryptography;
using CookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy; using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy;
namespace Mirea.Api.Endpoint.Controllers.Configuration; namespace Mirea.Api.Endpoint.Controllers.Configuration;
@ -539,7 +540,7 @@ public class SetupController(
[TokenAuthentication] [TokenAuthentication]
public ActionResult<bool> SetPasswordPolicy([FromBody] PasswordPolicy? policy = null) public ActionResult<bool> SetPasswordPolicy([FromBody] PasswordPolicy? policy = null)
{ {
GeneralConfig.PasswordPolicy = policy?.ConvertFromDto() ?? new Security.Common.Domain.PasswordPolicy(); GeneralConfig.PasswordPolicy = policy?.ConvertFromDto() ?? new Security.Common.Model.PasswordPolicy();
cache.Set("password", true); cache.Set("password", true);
return true; return true;
} }

View File

@ -11,13 +11,15 @@ using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.MapperDto; using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Common.Services; using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model; using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Services; using Mirea.Api.Security.Services;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using CookieOptions = Mirea.Api.Security.Common.Model.CookieOptions;
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider; using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
namespace Mirea.Api.Endpoint.Controllers.V1; namespace Mirea.Api.Endpoint.Controllers.V1;
@ -26,7 +28,7 @@ namespace Mirea.Api.Endpoint.Controllers.V1;
public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<GeneralConfig> generalConfig, AuthService auth, public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<GeneralConfig> generalConfig, AuthService auth,
PasswordHashService passwordService, OAuthService oAuthService) : BaseController PasswordHashService passwordService, OAuthService oAuthService) : BaseController
{ {
private CookieOptionsParameters GetCookieParams() => private CookieOptions GetCookieParams() =>
new() new()
{ {
Domain = HttpContext.GetCurrentDomain(), Domain = HttpContext.GetCurrentDomain(),
@ -53,6 +55,19 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
return $"<!DOCTYPE html><html lang=ru><head><meta charset=UTF-8><meta content=\"width=device-width,initial-scale=1\"name=viewport><link href=\"https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap\"rel=stylesheet><style>body{{background-color:#121212;color:#fff;font-family:Roboto,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;text-align:center}}.container{{max-width:600px;padding:20px;border-radius:8px;background-color:#1e1e1e;box-shadow:0 4px 20px rgba(0,0,0,.5)}}h1{{font-size:24px;margin-bottom:20px}}</style><title>{title}</title></head><body><div class=container><h1>{title}</h1>{blockInfo}<p style=font-size:14px;color:silver;>{message}</p><code style=font-size:12px;color:gray;>TraceId={traceId}</code></div>{script}</body></html>"; return $"<!DOCTYPE html><html lang=ru><head><meta charset=UTF-8><meta content=\"width=device-width,initial-scale=1\"name=viewport><link href=\"https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap\"rel=stylesheet><style>body{{background-color:#121212;color:#fff;font-family:Roboto,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;text-align:center}}.container{{max-width:600px;padding:20px;border-radius:8px;background-color:#1e1e1e;box-shadow:0 4px 20px rgba(0,0,0,.5)}}h1{{font-size:24px;margin-bottom:20px}}</style><title>{title}</title></head><body><div class=container><h1>{title}</h1>{blockInfo}<p style=font-size:14px;color:silver;>{message}</p><code style=font-size:12px;color:gray;>TraceId={traceId}</code></div>{script}</body></html>";
} }
/// <summary>
/// Handles the callback from an OAuth2 provider and finalizes the authorization process.
/// </summary>
/// <remarks>
/// This method processes the response from an OAuth provider after the user authorizes the application.
/// Upon successful authorization, it redirects the user back to the specified callback URL.
/// </remarks>
/// <param name="code">The authorization code returned by the OAuth provider.</param>
/// <param name="state">The state parameter to ensure the request's integrity and prevent CSRF attacks.</param>
/// <returns>
/// An HTML response indicating the success or failure of the authorization process.
/// If a callback URL is provided, the user will be redirected to it.
/// </returns>
[HttpGet("OAuth2")] [HttpGet("OAuth2")]
[BadRequestResponse] [BadRequestResponse]
[Produces("text/html")] [Produces("text/html")]
@ -69,7 +84,7 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
traceId, traceId,
true), "text/html"); true), "text/html");
var result = await oAuthService.LoginOAuth(HttpContext, GetCookieParams(), var result = await oAuthService.LoginOAuth(GetCookieParams(), HttpContext,
HttpContext.GetApiUrl(Url.Action("OAuth2")!), code, state); HttpContext.GetApiUrl(Url.Action("OAuth2")!), code, state);
string? callbackUrl = null; string? callbackUrl = null;
@ -122,7 +137,7 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
if (!callback.IsAbsoluteUri) if (!callback.IsAbsoluteUri)
throw new ControllerArgumentException("The callback URL must be absolute."); throw new ControllerArgumentException("The callback URL must be absolute.");
return Redirect(oAuthService.GetProviderRedirect(HttpContext, GetCookieParams(), return Redirect(oAuthService.GetProviderRedirect(GetCookieParams(), HttpContext,
HttpContext.GetApiUrl(Url.Action("OAuth2")!), HttpContext.GetApiUrl(Url.Action("OAuth2")!),
(OAuthProvider)provider, (OAuthProvider)provider,
callback).AbsoluteUri); callback).AbsoluteUri);
@ -150,6 +165,67 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
}) })
.ConvertToDto()); .ConvertToDto());
/// <summary>
/// Processes the OAuth token
/// </summary>
/// <param name="token">The OAuth token used for authentication or binding.</param>
/// <param name="action">The action to be performed: Login or Bind.</param>
/// <returns>If <see cref="OAuthAction.Bind"/> return Ok. If <see cref="OAuthAction.Login"/> return <see cref="TwoFactorAuthentication"/></returns>
[HttpGet("HandleToken")]
[MaintenanceModeIgnore]
[BadRequestResponse]
public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token, [FromQuery] OAuthAction action)
{
var (oAuthUser, error, isSuccess, provider) = await oAuthService.GetOAuthUser(GetCookieParams(), HttpContext, token);
if (!isSuccess || oAuthUser == null || provider == null)
throw new ControllerArgumentException(error ?? "Token processing error.");
switch (action)
{
case OAuthAction.Login:
return Ok(await auth.LoginOAuthAsync(GetCookieParams(), HttpContext, user.Value.ConvertToSecurity(), oAuthUser, provider.Value));
case OAuthAction.Bind:
var userId = HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
var admin = user.Value;
if (string.IsNullOrEmpty(userId) || !int.TryParse(userId, out var result) || result != 1)
return Unauthorized(new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2",
Title = "Unauthorized",
Status = StatusCodes.Status401Unauthorized,
Detail = "The user is not logged in to link accounts.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
if (admin.OAuthProviders != null && admin.OAuthProviders.ContainsKey(provider.Value))
return Conflict(new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10",
Title = "Conflict",
Status = StatusCodes.Status409Conflict,
Detail = "This OAuth provider is already associated with the account.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
admin.OAuthProviders ??= [];
admin.OAuthProviders.Add(provider.Value, oAuthUser);
admin.SaveSetting();
return Ok();
default:
throw new ControllerArgumentException("The action cannot be processed.");
}
}
/// <summary> /// <summary>
/// Logs in a user using their username or email and password. /// Logs in a user using their username or email and password.
/// </summary> /// </summary>
@ -163,18 +239,9 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
var tokenResult = await auth.LoginAsync( var tokenResult = await auth.LoginAsync(
GetCookieParams(), GetCookieParams(),
new User HttpContext,
{ userEntity.ConvertToSecurity(),
Id = 1.ToString(), request.Password, request.Username);
Username = userEntity.Username,
Email = userEntity.Email,
PasswordHash = userEntity.PasswordHash,
Salt = userEntity.Salt,
TwoFactorAuthenticator = userEntity.TwoFactorAuthenticator,
SecondFactorToken = userEntity.Secret,
OAuthProviders = userEntity.OAuthProviders
},
HttpContext, request.Password, request.Username);
return Ok(tokenResult.ConvertToDto()); return Ok(tokenResult.ConvertToDto());
} }
@ -186,11 +253,8 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
/// <returns>A boolean indicating whether the two-factor authentication was successful.</returns> /// <returns>A boolean indicating whether the two-factor authentication was successful.</returns>
[HttpPost("2FA")] [HttpPost("2FA")]
[BadRequestResponse] [BadRequestResponse]
public async Task<ActionResult<bool>> TwoFactorAuth([FromBody] TwoFactorAuthRequest request) public async Task<ActionResult<bool>> TwoFactorAuth([FromBody] TwoFactorAuthRequest request) =>
{ await auth.LoginAsync(GetCookieParams(), HttpContext, request.Method.ConvertFromDto(), request.Code);
var tokenResult = await auth.LoginAsync(GetCookieParams(), HttpContext, request.Method.ConvertFromDto(), request.Code);
return Ok(tokenResult);
}
/// <summary> /// <summary>
/// Refreshes the authentication token using the existing refresh token. /// Refreshes the authentication token using the existing refresh token.

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
using System; using System;
namespace Mirea.Api.Security.Common.Domain.OAuth2; namespace Mirea.Api.Security.Common.Domain;
internal readonly struct OAuthProviderUrisData internal readonly struct OAuthProviderUrisData
{ {

View File

@ -7,7 +7,7 @@ namespace Mirea.Api.Security.Common.Domain;
internal class RequestContextInfo internal class RequestContextInfo
{ {
public RequestContextInfo(HttpContext context, CookieOptionsParameters cookieOptions) public RequestContextInfo(HttpContext context, Model.CookieOptions cookieOptions)
{ {
var ipEntity = context.Connection.RemoteIpAddress; var ipEntity = context.Connection.RemoteIpAddress;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
using System; using System;
namespace Mirea.Api.Security.Common.Domain; namespace Mirea.Api.Security.Common.ViewModel;
public class LoginOAuthResult public class LoginOAuth
{ {
public bool Success { get; set; } public bool Success { get; set; }
public required string Token { get; set; } public required string Token { get; set; }

View File

@ -4,11 +4,13 @@ using Mirea.Api.Security.Common;
using Mirea.Api.Security.Common.Domain; using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Domain.Caching; using Mirea.Api.Security.Common.Domain.Caching;
using Mirea.Api.Security.Common.Interfaces; using Mirea.Api.Security.Common.Interfaces;
using Mirea.Api.Security.Common.Model;
using System; using System;
using System.Security; using System.Security;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CookieOptions = Mirea.Api.Security.Common.Model.CookieOptions;
namespace Mirea.Api.Security.Services; namespace Mirea.Api.Security.Services;
@ -94,7 +96,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
throw new SecurityException("Authentication failed. Please check your credentials."); throw new SecurityException("Authentication failed. Please check your credentials.");
} }
private async Task GenerateAuthTokensAsync(CookieOptionsParameters cookieOptions, HttpContext context, private async Task GenerateAuthTokensAsync(CookieOptions cookieOptions, HttpContext context,
RequestContextInfo requestContext, string userId, CancellationToken cancellation = default) RequestContextInfo requestContext, string userId, CancellationToken cancellation = default)
{ {
var refreshToken = GenerateRefreshToken(); var refreshToken = GenerateRefreshToken();
@ -118,23 +120,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
authToken.Fingerprint); authToken.Fingerprint);
} }
public async Task<TwoFactorAuthenticator> LoginOAuthAsync(CookieOptionsParameters cookieOptions, HttpContext context, User user, public async Task<bool> LoginAsync(CookieOptions cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code,
CancellationToken cancellation = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
{
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation);
return TwoFactorAuthenticator.None;
}
await StoreFirstAuthTokenInCache(user, requestContext, cancellation);
return user.TwoFactorAuthenticator;
}
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code,
CancellationToken cancellation = default) CancellationToken cancellation = default)
{ {
var requestContext = new RequestContextInfo(context, cookieOptions); var requestContext = new RequestContextInfo(context, cookieOptions);
@ -176,12 +162,12 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
return true; return true;
} }
public async Task<TwoFactorAuthenticator> LoginAsync(CookieOptionsParameters cookieOptions, User user, HttpContext context, string password, private async Task<TwoFactorAuthenticator> LoginAsync(CookieOptions cookieOptions,
string username, CancellationToken cancellation = default) HttpContext context,
User user,
CancellationToken cancellation = default)
{ {
var requestContext = new RequestContextInfo(context, cookieOptions); var requestContext = new RequestContextInfo(context, cookieOptions);
username = username.Trim();
await VerifyUserOrThrowError(requestContext, user, password, username, cancellation);
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None) if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
{ {
@ -194,7 +180,37 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
return user.TwoFactorAuthenticator; return user.TwoFactorAuthenticator;
} }
public async Task RefreshTokenAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default) public Task<TwoFactorAuthenticator> LoginOAuthAsync(CookieOptions cookieOptions,
HttpContext context,
User user,
OAuthUser oAuthUser,
OAuthProvider provider,
CancellationToken cancellation = default)
{
if (user.OAuthProviders == null || !user.OAuthProviders.TryGetValue(provider, out var value))
throw new SecurityException($"This provider '{Enum.GetName(provider)}' is not linked to the account.");
if (value.Id != oAuthUser.Id)
throw new SecurityException("This account was not linked");
return LoginAsync(cookieOptions, context, user, cancellation);
}
public async Task<TwoFactorAuthenticator> LoginAsync(CookieOptions cookieOptions,
HttpContext context,
User user,
string password,
string username,
CancellationToken cancellation = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
username = username.Trim();
await VerifyUserOrThrowError(requestContext, user, password, username, cancellation);
return await LoginAsync(cookieOptions, context, user, cancellation);
}
public async Task RefreshTokenAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellation = default)
{ {
const string defaultMessageError = "The session time has expired"; const string defaultMessageError = "The session time has expired";
var requestContext = new RequestContextInfo(context, cookieOptions); var requestContext = new RequestContextInfo(context, cookieOptions);
@ -271,7 +287,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime)); cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
} }
public async Task LogoutAsync(CookieOptionsParameters cookieOptions, HttpContext context, CancellationToken cancellation = default) public async Task LogoutAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellation = default)
{ {
var requestContext = new RequestContextInfo(context, cookieOptions); var requestContext = new RequestContextInfo(context, cookieOptions);

View File

@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Mirea.Api.Security.Common.Domain; using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Domain.OAuth2; using Mirea.Api.Security.Common.Domain.Caching;
using Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
using Mirea.Api.Security.Common.Interfaces; using Mirea.Api.Security.Common.Interfaces;
using Mirea.Api.Security.Common.OAuth2;
using Mirea.Api.Security.Common.OAuth2.UserInfo;
using Mirea.Api.Security.Common.ViewModel;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
@ -15,6 +17,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CookieOptions = Mirea.Api.Security.Common.Model.CookieOptions;
namespace Mirea.Api.Security.Services; namespace Mirea.Api.Security.Services;
@ -164,7 +167,15 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
} }
} }
public Uri GetProviderRedirect(HttpContext context, CookieOptionsParameters cookieOptions, string redirectUri, private Task StoreOAuthUserInCache(string key, OAuthUserExtension data, CancellationToken cancellation) =>
cache.SetAsync(
key,
JsonSerializer.SerializeToUtf8Bytes(data),
slidingExpiration: TimeSpan.FromMinutes(15),
cancellationToken: cancellation);
public Uri GetProviderRedirect(CookieOptions cookieOptions, HttpContext context, string redirectUri,
OAuthProvider provider, Uri callback) OAuthProvider provider, Uri callback)
{ {
var (clientId, _) = providers[provider]; var (clientId, _) = providers[provider];
@ -195,24 +206,37 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
public (OAuthProvider Provider, Uri Redirect)[] GetAvailableProviders(string redirectUri) => public (OAuthProvider Provider, Uri Redirect)[] GetAvailableProviders(string redirectUri) =>
[.. providers.Select(x => (x.Key, new Uri(redirectUri.TrimEnd('/') + "/?provider=" + (int)x.Key)))]; [.. providers.Select(x => (x.Key, new Uri(redirectUri.TrimEnd('/') + "/?provider=" + (int)x.Key)))];
public async Task<LoginOAuthResult> LoginOAuth(HttpContext context, CookieOptionsParameters cookieOptions, public async Task<LoginOAuth> LoginOAuth(CookieOptions cookieOptions, HttpContext context,
string redirectUrl, string code, string state, CancellationToken cancellation = default) string redirectUrl, string code, string state, CancellationToken cancellation = default)
{ {
var result = new LoginOAuthResult() var result = new LoginOAuth()
{ {
Token = GeneratorKey.GenerateBase64(32) Token = GeneratorKey.GenerateBase64(32)
}; };
var parts = state.Split('_'); var parts = state.Split('_');
if (parts.Length != 2) if (parts.Length != 2)
{ {
result.ErrorMessage = "The request data is invalid or malformed."; result.ErrorMessage = "The request data is invalid or malformed.";
await StoreOAuthUserInCache(result.Token, new OAuthUserExtension()
{
Message = result.ErrorMessage,
Provider = null
}, cancellation);
return result; return result;
} }
var payload = DecryptPayload(parts[0]); var payload = DecryptPayload(parts[0]);
var checksum = parts[1]; var checksum = parts[1];
var cacheData = new OAuthUserExtension()
{
Provider = payload.Provider
};
result.Callback = new Uri(payload.Callback); result.Callback = new Uri(payload.Callback);
if (!providers.TryGetValue(payload.Provider, out var providerInfo) || if (!providers.TryGetValue(payload.Provider, out var providerInfo) ||
@ -223,6 +247,10 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
state); state);
result.ErrorMessage = "Invalid authorization request. Please try again later."; result.ErrorMessage = "Invalid authorization request. Please try again later.";
cacheData.Message = result.ErrorMessage;
await StoreOAuthUserInCache(result.Token, cacheData, cancellation);
return result; return result;
} }
@ -230,6 +258,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
var checksumRequest = GetHmacString(requestInfo); var checksumRequest = GetHmacString(requestInfo);
result.ErrorMessage = "Authorization failed. Please try again later."; result.ErrorMessage = "Authorization failed. Please try again later.";
cacheData.Message = result.ErrorMessage;
if (checksumRequest != checksum) if (checksumRequest != checksum)
{ {
@ -240,6 +269,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
checksum checksum
); );
await StoreOAuthUserInCache(result.Token, cacheData, cancellation);
return result; return result;
} }
@ -255,6 +286,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
payload.Provider, payload.Provider,
checksum); checksum);
await StoreOAuthUserInCache(result.Token, cacheData, cancellation);
return result; return result;
} }
@ -272,6 +305,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
logger.LogWarning(ex, "Failed to retrieve user information from provider {Provider}", logger.LogWarning(ex, "Failed to retrieve user information from provider {Provider}",
payload.Provider); payload.Provider);
await StoreOAuthUserInCache(result.Token, cacheData, cancellation);
return result; return result;
} }
@ -281,12 +316,75 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
result.ErrorMessage = null; result.ErrorMessage = null;
result.Success = true; result.Success = true;
await cache.SetAsync( await StoreOAuthUserInCache(result.Token, new OAuthUserExtension
result.Token, {
JsonSerializer.SerializeToUtf8Bytes(user), IsSuccess = true,
absoluteExpirationRelativeToNow: TimeSpan.FromMinutes(15), User = user,
cancellationToken: cancellation); Provider = payload.Provider
}, cancellation);
return result; return result;
} }
public async Task<(OAuthUser? User, string? Message, bool IsSuccess, OAuthProvider? Provider)>
GetOAuthUser(CookieOptions cookieOptions, HttpContext context, string token, CancellationToken cancellation = default)
{
var requestInfo = new RequestContextInfo(context, cookieOptions);
var result = await cache.GetAsync<OAuthUserExtension>(token, cancellation);
string tokenFailedKey = $"{requestInfo.Fingerprint}_oauth_token_failed";
if (result == null)
{
var failedTokenAttemptsCount = await cache.GetAsync<int?>(
tokenFailedKey,
cancellation) ?? 1;
var failedTokenCacheExpiration = TimeSpan.FromHours(1);
if (failedTokenAttemptsCount > 5)
{
logger.LogWarning(
"Multiple unsuccessful token attempts detected. Token {Token}, Fingerprint: {Fingerprint}. Attempt count: {AttemptCount}.",
token,
requestInfo.Fingerprint,
failedTokenAttemptsCount);
return (null, "Too many unsuccessful token attempts. Please try again later.", false, null);
}
logger.LogInformation(
"Cache data not found or expired for token: {Token}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
token,
requestInfo.Fingerprint,
failedTokenAttemptsCount);
await cache.SetAsync(tokenFailedKey,
failedTokenAttemptsCount + 1,
slidingExpiration: failedTokenCacheExpiration,
cancellationToken: cancellation);
return (null, "Invalid or expired token.", false, null);
}
await cache.RemoveAsync(tokenFailedKey, cancellation);
const string log = "Cache data retrieved for token: {Token}. Fingerprint: {Fingerprint}.";
if (result.User != null)
logger.LogInformation(log + " Provider: {Provider}. UserId: {UserId}.",
token,
requestInfo.Fingerprint,
result.User.Id,
result.Provider);
else if (result.Provider != null)
logger.LogInformation(log + " Provider: {Provider}.",
token,
requestInfo.Fingerprint,
result.Provider);
else
logger.LogInformation(log, token, requestInfo.Fingerprint);
return (result.User, result.Message, result.IsSuccess, result.Provider);
}
} }

View File

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