Compare commits
8 Commits
07111b9b61
...
92081156cf
Author | SHA1 | Date | |
---|---|---|---|
92081156cf | |||
6358410f18 | |||
e79ddf220f | |||
c3c9844e2f | |||
206720cd63 | |||
d9f4176aca | |||
1de344ac25 | |||
61a11ea223 |
@ -65,11 +65,13 @@ public class CustomExceptionHandlerMiddleware(RequestDelegate next, ILogger<Cust
|
||||
problemDetails.Status = StatusCodes.Status400BadRequest;
|
||||
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1";
|
||||
problemDetails.Title = "Invalid arguments provided.";
|
||||
problemDetails.Detail = exception.Message;
|
||||
break;
|
||||
case SecurityException:
|
||||
problemDetails.Status = StatusCodes.Status401Unauthorized;
|
||||
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
|
||||
problemDetails.Title = "Unauthorized access.";
|
||||
problemDetails.Detail = exception.Message;
|
||||
break;
|
||||
case ServerUnavailableException unavailableException:
|
||||
problemDetails.Status = StatusCodes.Status503ServiceUnavailable;
|
||||
|
@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mirea.Api.Dto.Common;
|
||||
using Mirea.Api.Dto.Requests;
|
||||
using Mirea.Api.Dto.Requests.Configuration;
|
||||
@ -18,6 +17,7 @@ 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.Common.Model;
|
||||
using Mirea.Api.Security.Services;
|
||||
using MySqlConnector;
|
||||
@ -26,13 +26,17 @@ using Serilog;
|
||||
using StackExchange.Redis;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using CookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
|
||||
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
|
||||
using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy;
|
||||
|
||||
namespace Mirea.Api.Endpoint.Controllers.Configuration;
|
||||
@ -45,7 +49,7 @@ public class SetupController(
|
||||
IMaintenanceModeNotConfigureService notConfigureService,
|
||||
IMemoryCache cache,
|
||||
PasswordHashService passwordHashService,
|
||||
IOptionsSnapshot<Admin> user) : BaseController
|
||||
OAuthService oAuthService) : BaseController
|
||||
{
|
||||
private const string CacheGeneralKey = "config_general";
|
||||
private const string CacheAdminKey = "config_admin";
|
||||
@ -319,29 +323,54 @@ public class SetupController(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
[HttpGet("UpdateAdminConfiguration")]
|
||||
[HttpGet("HandleToken")]
|
||||
[TokenAuthentication]
|
||||
public ActionResult UpdateAdminConfiguration()
|
||||
public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(user.Value.Email))
|
||||
return Ok();
|
||||
var (user, error, isSuccess, provider) = await oAuthService.GetOAuthUser(new Security.Common.Model.CookieOptions
|
||||
{
|
||||
Domain = HttpContext.GetCurrentDomain(),
|
||||
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
|
||||
}, HttpContext, token);
|
||||
|
||||
if (!isSuccess || user == null || provider == null)
|
||||
throw new ControllerArgumentException(error ?? "Token processing error.");
|
||||
|
||||
if (!cache.TryGetValue<Admin>(CacheAdminKey, out var admin))
|
||||
{
|
||||
admin = user.Value;
|
||||
admin = new Admin()
|
||||
{
|
||||
Email = user.Email ?? string.Empty,
|
||||
Username = user.Username ?? string.Empty,
|
||||
PasswordHash = string.Empty,
|
||||
Salt = string.Empty,
|
||||
OAuthProviders = new Dictionary<OAuthProvider, OAuthUser>
|
||||
{
|
||||
{provider.Value, user}
|
||||
}
|
||||
};
|
||||
|
||||
cache.Set(CacheAdminKey, admin);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
admin!.OAuthProviders = user.Value.OAuthProviders;
|
||||
|
||||
if (string.IsNullOrEmpty(admin.Email))
|
||||
admin.Email = user.Value.Email;
|
||||
|
||||
if (string.IsNullOrEmpty(admin.Username))
|
||||
admin.Username = user.Value.Username;
|
||||
if (admin!.OAuthProviders != null && admin.OAuthProviders.ContainsKey(provider.Value))
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10",
|
||||
Title = "Conflict",
|
||||
Status = StatusCodes.Status409Conflict,
|
||||
Detail = "This OAuth provider is already associated with the account.",
|
||||
Extensions = new Dictionary<string, object?>()
|
||||
{
|
||||
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
|
||||
}
|
||||
});
|
||||
|
||||
admin.OAuthProviders ??= [];
|
||||
admin.OAuthProviders.Add(provider.Value, user);
|
||||
cache.Set(CacheAdminKey, admin);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
@ -172,7 +172,6 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
|
||||
/// <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)
|
||||
{
|
||||
|
@ -5,5 +5,8 @@ internal class OAuthUserExtension
|
||||
public string? Message { get; set; }
|
||||
public bool IsSuccess { get; set; }
|
||||
public required OAuthProvider? Provider { get; set; }
|
||||
public string? UserAgent { get; set; } = null;
|
||||
public string? Ip { get; set; } = null;
|
||||
public string? Fingerprint { get; set; } = null;
|
||||
public OAuthUser? User { get; set; }
|
||||
}
|
@ -226,26 +226,18 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
cookieOptions.DropCookie(context, CookieNames.AccessToken);
|
||||
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
|
||||
|
||||
const string error = "Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. ";
|
||||
if (authToken.RefreshToken != requestContext.RefreshToken)
|
||||
logger.LogWarning(
|
||||
error +
|
||||
"Cached refresh token {ExpectedRefreshToken} does not match the provided refresh token {RefreshToken}",
|
||||
authToken.UserId,
|
||||
authToken.Fingerprint,
|
||||
authToken.RefreshToken,
|
||||
requestContext.RefreshToken);
|
||||
else
|
||||
logger.LogWarning(
|
||||
error +
|
||||
"User-Agent {ExpectedUserAgent} and IP {ExpectedUserIp} in cache do not match the provided " +
|
||||
"User-Agent {ProvidedUserAgent} and IP {ProvidedIp}",
|
||||
authToken.UserId,
|
||||
authToken.Fingerprint,
|
||||
authToken.UserAgent,
|
||||
authToken.Ip,
|
||||
requestContext.UserAgent,
|
||||
requestContext.Ip);
|
||||
logger.LogWarning("Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. " +
|
||||
"RefreshToken: {ExpectedRefreshToken} -> {RefreshToken}, " +
|
||||
"UserAgent: {ExpectedUserAgent} -> {ProvidedUserAgent}, " +
|
||||
"Ip: {ExpectedUserIp} -> {ProvidedIp}",
|
||||
authToken.UserId,
|
||||
authToken.Fingerprint,
|
||||
authToken.RefreshToken,
|
||||
requestContext.RefreshToken,
|
||||
authToken.UserAgent,
|
||||
requestContext.UserAgent,
|
||||
authToken.Ip,
|
||||
requestContext.Ip);
|
||||
|
||||
throw new SecurityException(defaultMessageError);
|
||||
}
|
||||
|
@ -171,7 +171,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
||||
cache.SetAsync(
|
||||
key,
|
||||
JsonSerializer.SerializeToUtf8Bytes(data),
|
||||
slidingExpiration: TimeSpan.FromMinutes(15),
|
||||
absoluteExpirationRelativeToNow: TimeSpan.FromMinutes(15),
|
||||
cancellationToken: cancellation);
|
||||
|
||||
|
||||
@ -193,7 +193,9 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
||||
"&response_type=code" +
|
||||
$"&redirect_uri={redirectUri}" +
|
||||
$"&scope={ProviderData[provider].Scope}" +
|
||||
$"&state={Uri.EscapeDataString(payload + "_" + checksum)}";
|
||||
$"&state={Uri.EscapeDataString(payload + "_" + checksum)}" +
|
||||
"&prompt=select_account" +
|
||||
"&force_confirm=true";
|
||||
|
||||
logger.LogInformation("Redirecting user Fingerprint: {Fingerprint} to OAuth provider {Provider} with state: {State}",
|
||||
requestInfo.Fingerprint,
|
||||
@ -332,7 +334,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
||||
var requestInfo = new RequestContextInfo(context, cookieOptions);
|
||||
|
||||
var result = await cache.GetAsync<OAuthUserExtension>(token, cancellation);
|
||||
string tokenFailedKey = $"{requestInfo.Fingerprint}_oauth_token_failed";
|
||||
var tokenFailedKey = $"{requestInfo.Fingerprint}_oauth_token_failed";
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
@ -367,8 +369,6 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
||||
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)
|
||||
@ -385,6 +385,40 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
||||
else
|
||||
logger.LogInformation(log, token, requestInfo.Fingerprint);
|
||||
|
||||
if ((!string.IsNullOrEmpty(result.Fingerprint) &&
|
||||
result.Fingerprint != requestInfo.Fingerprint) ||
|
||||
(!string.IsNullOrEmpty(result.UserAgent) &&
|
||||
result.UserAgent != requestInfo.UserAgent &&
|
||||
!string.IsNullOrEmpty(result.Ip)) &&
|
||||
result.Ip != requestInfo.Ip)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Potential token compromise detected. " +
|
||||
"Token {Token} has been used from different location. " +
|
||||
"Fingerprint: {ExpectedFingerprint} -> {ProvidedFingerprint}, " +
|
||||
"UserAgent: {ExpectedUserAgent} -> {ProvidedUserAgent}, " +
|
||||
"Ip: {ExpectedUserIp} -> {ProvidedIp}",
|
||||
token,
|
||||
result.Fingerprint,
|
||||
requestInfo.Fingerprint,
|
||||
result.UserAgent,
|
||||
requestInfo.UserAgent,
|
||||
result.Ip,
|
||||
requestInfo.Ip);
|
||||
|
||||
await cache.RemoveAsync(token, cancellation);
|
||||
|
||||
return (null, "Invalid or expired token.", false, null);
|
||||
}
|
||||
|
||||
await cache.RemoveAsync(tokenFailedKey, cancellation);
|
||||
|
||||
result.Ip = requestInfo.Ip;
|
||||
result.UserAgent = requestInfo.UserAgent;
|
||||
result.Fingerprint = requestInfo.Fingerprint;
|
||||
|
||||
await StoreOAuthUserInCache(token, result, cancellation);
|
||||
|
||||
return (result.User, result.Message, result.IsSuccess, result.Provider);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user