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.Status = StatusCodes.Status400BadRequest;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1"; problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1";
problemDetails.Title = "Invalid arguments provided."; problemDetails.Title = "Invalid arguments provided.";
problemDetails.Detail = exception.Message;
break; break;
case SecurityException: case SecurityException:
problemDetails.Status = StatusCodes.Status401Unauthorized; problemDetails.Status = StatusCodes.Status401Unauthorized;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2"; problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
problemDetails.Title = "Unauthorized access."; problemDetails.Title = "Unauthorized access.";
problemDetails.Detail = exception.Message;
break; break;
case ServerUnavailableException unavailableException: case ServerUnavailableException unavailableException:
problemDetails.Status = StatusCodes.Status503ServiceUnavailable; problemDetails.Status = StatusCodes.Status503ServiceUnavailable;

View File

@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Mirea.Api.Dto.Common; using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Requests; using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Requests.Configuration; 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;
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.Common.Model;
using Mirea.Api.Security.Services; using Mirea.Api.Security.Services;
using MySqlConnector; using MySqlConnector;
@ -26,13 +26,17 @@ using Serilog;
using StackExchange.Redis; using StackExchange.Redis;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Data; using System.Data;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; 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 System.Threading.Tasks;
using CookieOptions = Microsoft.AspNetCore.Http.CookieOptions; using CookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
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;
@ -45,7 +49,7 @@ public class SetupController(
IMaintenanceModeNotConfigureService notConfigureService, IMaintenanceModeNotConfigureService notConfigureService,
IMemoryCache cache, IMemoryCache cache,
PasswordHashService passwordHashService, PasswordHashService passwordHashService,
IOptionsSnapshot<Admin> user) : BaseController OAuthService oAuthService) : BaseController
{ {
private const string CacheGeneralKey = "config_general"; private const string CacheGeneralKey = "config_general";
private const string CacheAdminKey = "config_admin"; private const string CacheAdminKey = "config_admin";
@ -319,29 +323,54 @@ public class SetupController(
return Ok(true); return Ok(true);
} }
[HttpGet("UpdateAdminConfiguration")] [HttpGet("HandleToken")]
[TokenAuthentication] [TokenAuthentication]
public ActionResult UpdateAdminConfiguration() public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token)
{ {
if (string.IsNullOrEmpty(user.Value.Email)) var (user, error, isSuccess, provider) = await oAuthService.GetOAuthUser(new Security.Common.Model.CookieOptions
return Ok(); {
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)) 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); cache.Set(CacheAdminKey, admin);
return Ok(); return Ok();
} }
admin!.OAuthProviders = user.Value.OAuthProviders; if (admin!.OAuthProviders != null && admin.OAuthProviders.ContainsKey(provider.Value))
return Conflict(new ProblemDetails
if (string.IsNullOrEmpty(admin.Email)) {
admin.Email = user.Value.Email; Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10",
Title = "Conflict",
if (string.IsNullOrEmpty(admin.Username)) Status = StatusCodes.Status409Conflict,
admin.Username = user.Value.Username; 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); cache.Set(CacheAdminKey, admin);
return Ok(); 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> /// <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> /// <returns>If <see cref="OAuthAction.Bind"/> return Ok. If <see cref="OAuthAction.Login"/> return <see cref="TwoFactorAuthentication"/></returns>
[HttpGet("HandleToken")] [HttpGet("HandleToken")]
[MaintenanceModeIgnore]
[BadRequestResponse] [BadRequestResponse]
public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token, [FromQuery] OAuthAction action) 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 string? Message { get; set; }
public bool IsSuccess { get; set; } public bool IsSuccess { get; set; }
public required OAuthProvider? Provider { 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; } 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.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken); cookieOptions.DropCookie(context, CookieNames.RefreshToken);
const string error = "Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. "; logger.LogWarning("Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. " +
if (authToken.RefreshToken != requestContext.RefreshToken) "RefreshToken: {ExpectedRefreshToken} -> {RefreshToken}, " +
logger.LogWarning( "UserAgent: {ExpectedUserAgent} -> {ProvidedUserAgent}, " +
error + "Ip: {ExpectedUserIp} -> {ProvidedIp}",
"Cached refresh token {ExpectedRefreshToken} does not match the provided refresh token {RefreshToken}", authToken.UserId,
authToken.UserId, authToken.Fingerprint,
authToken.Fingerprint, authToken.RefreshToken,
authToken.RefreshToken, requestContext.RefreshToken,
requestContext.RefreshToken); authToken.UserAgent,
else requestContext.UserAgent,
logger.LogWarning( authToken.Ip,
error + requestContext.Ip);
"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);
throw new SecurityException(defaultMessageError); throw new SecurityException(defaultMessageError);
} }

View File

@ -171,7 +171,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
cache.SetAsync( cache.SetAsync(
key, key,
JsonSerializer.SerializeToUtf8Bytes(data), JsonSerializer.SerializeToUtf8Bytes(data),
slidingExpiration: TimeSpan.FromMinutes(15), absoluteExpirationRelativeToNow: TimeSpan.FromMinutes(15),
cancellationToken: cancellation); cancellationToken: cancellation);
@ -193,7 +193,9 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
"&response_type=code" + "&response_type=code" +
$"&redirect_uri={redirectUri}" + $"&redirect_uri={redirectUri}" +
$"&scope={ProviderData[provider].Scope}" + $"&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}", logger.LogInformation("Redirecting user Fingerprint: {Fingerprint} to OAuth provider {Provider} with state: {State}",
requestInfo.Fingerprint, requestInfo.Fingerprint,
@ -332,7 +334,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
var requestInfo = new RequestContextInfo(context, cookieOptions); var requestInfo = new RequestContextInfo(context, cookieOptions);
var result = await cache.GetAsync<OAuthUserExtension>(token, cancellation); 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) if (result == null)
{ {
@ -367,8 +369,6 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
return (null, "Invalid or expired token.", false, null); return (null, "Invalid or expired token.", false, null);
} }
await cache.RemoveAsync(tokenFailedKey, cancellation);
const string log = "Cache data retrieved for token: {Token}. Fingerprint: {Fingerprint}."; const string log = "Cache data retrieved for token: {Token}. Fingerprint: {Fingerprint}.";
if (result.User != null) if (result.User != null)
@ -385,6 +385,40 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
else else
logger.LogInformation(log, token, requestInfo.Fingerprint); 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); return (result.User, result.Message, result.IsSuccess, result.Provider);
} }
} }