Compare commits

...

8 Commits

Author SHA1 Message Date
92081156cf fix: save token after update
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m23s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m13s
2024-12-28 08:34:19 +03:00
6358410f18 sec: to establish the ownership of the token for the first one who received it 2024-12-28 08:30:56 +03:00
e79ddf220f sec: set the absolute time of the token 2024-12-28 08:29:31 +03:00
c3c9844e2f refactor: improve logging 2024-12-28 08:29:06 +03:00
206720cd63 fix: add force select account 2024-12-28 08:16:00 +03:00
d9f4176aca fix: return message if 401 2024-12-28 08:15:43 +03:00
1de344ac25 refactor: to enable oauth during registration, use the appropriate controller. 2024-12-28 07:46:06 +03:00
61a11ea223 fix: return exception message if controller exception 2024-12-28 06:47:21 +03:00
6 changed files with 99 additions and 40 deletions

View File

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

View File

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

View File

@ -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)
{

View File

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

View File

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

View File

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