do sessions in memory and also fix glaring security hole
This commit is contained in:
parent
7b6c155a73
commit
f48b421500
31 changed files with 441 additions and 440 deletions
|
@ -1,15 +1,11 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using Femto.Api.Sessions;
|
||||
using Femto.Common;
|
||||
using Femto.Modules.Auth.Application;
|
||||
using Femto.Modules.Auth.Application.Dto;
|
||||
using Femto.Modules.Auth.Application.Interface.ValidateSession;
|
||||
using Femto.Modules.Auth.Errors;
|
||||
using Femto.Modules.Auth.Application.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Extensions;
|
||||
|
||||
namespace Femto.Api.Auth;
|
||||
|
||||
|
@ -17,61 +13,84 @@ internal class SessionAuthenticationHandler(
|
|||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IAuthModule authModule,
|
||||
IAuthService authService,
|
||||
CurrentUserContext currentUserContext
|
||||
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var sessionId = this.Request.Cookies["session"];
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier);
|
||||
|
||||
var (sessionId, maybeUserId) = this.Context.GetSessionInfo();
|
||||
|
||||
|
||||
if (sessionId is null)
|
||||
{
|
||||
Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier);
|
||||
return AuthenticateResult.NoResult();
|
||||
|
||||
var userJson = this.Request.Cookies["user"];
|
||||
if (string.IsNullOrWhiteSpace(userJson))
|
||||
return AuthenticateResult.Fail("Invalid user");
|
||||
|
||||
var user = JsonSerializer.Deserialize<UserInfo>(userJson);
|
||||
|
||||
if (user is null)
|
||||
return AuthenticateResult.Fail("Invalid user");
|
||||
|
||||
var rememberMe = this.Request.Cookies["rememberme"];
|
||||
|
||||
try
|
||||
{
|
||||
var result = await authModule.Command(
|
||||
new ValidateSessionCommand(sessionId, user, rememberMe)
|
||||
);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, user.Username),
|
||||
new("sub", user.Id.ToString()),
|
||||
new("user_id", user.Id.ToString()),
|
||||
};
|
||||
|
||||
claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString())));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, this.Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
this.Context.SetSession(result.SessionDto, user, Logger);
|
||||
|
||||
currentUserContext.CurrentUser = new CurrentUser(
|
||||
user.Id,
|
||||
user.Username,
|
||||
result.SessionDto.SessionId,
|
||||
rememberMe
|
||||
);
|
||||
|
||||
return AuthenticateResult.Success(
|
||||
new AuthenticationTicket(principal, this.Scheme.Name)
|
||||
);
|
||||
}
|
||||
catch (InvalidSessionError)
|
||||
|
||||
var session = await authService.GetSession(sessionId);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid session");
|
||||
Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier);
|
||||
return await FailAndDeleteSession(sessionId);
|
||||
}
|
||||
|
||||
if (session.IsExpired)
|
||||
{
|
||||
Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier);
|
||||
return await FailAndDeleteSession(sessionId);
|
||||
}
|
||||
|
||||
if (maybeUserId is not { } userId)
|
||||
{
|
||||
Logger.LogDebug("{TraceId} SessionId provided with no user", this.Context.TraceIdentifier);
|
||||
return await FailAndDeleteSession(sessionId);
|
||||
}
|
||||
|
||||
if (session.UserId != userId)
|
||||
{
|
||||
Logger.LogDebug("{TraceId} SessionId provided with different user", this.Context.TraceIdentifier);
|
||||
return await FailAndDeleteSession(sessionId);
|
||||
}
|
||||
|
||||
var user = await authService.GetUserWithId(userId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await authService.DeleteSession(sessionId);
|
||||
this.Context.DeleteSession();
|
||||
return AuthenticateResult.Fail("invalid session");
|
||||
}
|
||||
|
||||
if (session.ExpiresSoon)
|
||||
{
|
||||
session = await authService.CreateWeakSession(userId);
|
||||
this.Context.SetSession(session, user);
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, user.Username),
|
||||
new("sub", user.Id.ToString()),
|
||||
new("user_id", user.Id.ToString()),
|
||||
};
|
||||
|
||||
claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString())));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, this.Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
currentUserContext.CurrentUser = new CurrentUser(user.Id, user.Username);
|
||||
|
||||
return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
|
||||
}
|
||||
|
||||
private async Task<AuthenticateResult> FailAndDeleteSession(string sessionId)
|
||||
{
|
||||
await authService.DeleteSession(sessionId);
|
||||
this.Context.DeleteSession();
|
||||
return AuthenticateResult.Fail("invalid session");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
using Femto.Api.Auth;
|
||||
using Femto.Api.Sessions;
|
||||
using Femto.Common;
|
||||
using Femto.Modules.Auth.Application;
|
||||
using Femto.Modules.Auth.Application.Dto;
|
||||
using Femto.Modules.Auth.Application.Interface.CreateSignupCode;
|
||||
using Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
|
||||
using Femto.Modules.Auth.Application.Interface.Login;
|
||||
using Femto.Modules.Auth.Application.Interface.RefreshUserSession;
|
||||
using Femto.Modules.Auth.Application.Interface.Register;
|
||||
using Femto.Modules.Auth.Application.Services;
|
||||
using Femto.Modules.Auth.Contracts;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
@ -21,79 +18,85 @@ public class AuthController(
|
|||
IAuthModule authModule,
|
||||
IOptions<CookieSettings> cookieSettings,
|
||||
ICurrentUserContext currentUserContext,
|
||||
ILogger<AuthController> logger
|
||||
ILogger<AuthController> logger,
|
||||
IAuthService authService
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request)
|
||||
public async Task<ActionResult<LoginResponse>> Login(
|
||||
[FromBody] LoginRequest request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await authModule.Command(new LoginCommand(request.Username, request.Password));
|
||||
|
||||
HttpContext.SetSession(result.SessionDto, result.User, logger);
|
||||
|
||||
return new LoginResponse(
|
||||
result.User.Id,
|
||||
result.User.Username,
|
||||
result.User.Roles.Any(r => r == Role.SuperUser)
|
||||
var user = await authService.GetUserWithCredentials(
|
||||
request.Username,
|
||||
request.Password,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (user is null)
|
||||
return Forbid();
|
||||
|
||||
var session = await authService.CreateStrongSession(user.Id);
|
||||
|
||||
HttpContext.SetSession(session, user);
|
||||
|
||||
return new LoginResponse(user.Id, user.Username, user.Roles.Any(r => r == Role.SuperUser));
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<ActionResult<RegisterResponse>> Register([FromBody] RegisterRequest request)
|
||||
{
|
||||
var result = await authModule.Command(
|
||||
var user = await authModule.Command(
|
||||
new RegisterCommand(request.Username, request.Password, request.SignupCode)
|
||||
);
|
||||
|
||||
HttpContext.SetSession(result.SessionDto, result.User, logger);
|
||||
var session = await authService.CreateStrongSession(user.Id);
|
||||
|
||||
HttpContext.SetSession(session, user);
|
||||
return new RegisterResponse(
|
||||
result.User.Id,
|
||||
result.User.Username,
|
||||
result.User.Roles.Any(r => r == Role.SuperUser)
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.Roles.Any(r => r == Role.SuperUser)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpDelete("session")]
|
||||
public async Task<ActionResult> DeleteSession()
|
||||
{
|
||||
var currentUser = currentUserContext.CurrentUser;
|
||||
var (sessionId, userId) = HttpContext.GetSessionInfo();
|
||||
|
||||
if (sessionId is not null)
|
||||
{
|
||||
await authService.DeleteSession(sessionId);
|
||||
HttpContext.DeleteSession();
|
||||
}
|
||||
|
||||
if (currentUser != null)
|
||||
await authModule.Command(new DeauthenticateCommand(currentUser.Id, currentUser.SessionId, currentUser.RememberMeToken));
|
||||
|
||||
HttpContext.DeleteSession();
|
||||
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpGet("user/{userId}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<RefreshUserResult>> RefreshUser(
|
||||
public async Task<ActionResult<GetUserInfoResult>> GetUserInfo(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var currentUser = currentUserContext.CurrentUser!;
|
||||
var currentUser = currentUserContext.CurrentUser;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await authModule.Command(
|
||||
new RefreshUserCommand(userId, currentUser),
|
||||
cancellationToken
|
||||
);
|
||||
if (currentUser is null || currentUser.Id != userId)
|
||||
return this.BadRequest();
|
||||
|
||||
return new RefreshUserResult(
|
||||
result.User.Id,
|
||||
result.User.Username,
|
||||
result.User.Roles.Any(r => r == Role.SuperUser)
|
||||
);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
HttpContext.DeleteSession();
|
||||
return this.Forbid();
|
||||
}
|
||||
var user = await authService.GetUserWithId(userId, cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
return this.BadRequest();
|
||||
|
||||
return new GetUserInfoResult(
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.Roles.Any(r => r == Role.SuperUser)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("signup-codes")]
|
||||
|
|
3
Femto.Api/Controllers/Auth/GetUserInfoResult.cs
Normal file
3
Femto.Api/Controllers/Auth/GetUserInfoResult.cs
Normal file
|
@ -0,0 +1,3 @@
|
|||
namespace Femto.Api.Controllers.Auth;
|
||||
|
||||
public record GetUserInfoResult(Guid UserId, string Username, bool IsSuperUser);
|
|
@ -1,3 +0,0 @@
|
|||
namespace Femto.Api.Controllers.Auth;
|
||||
|
||||
public record RefreshUserResult(Guid UserId, string Username, bool IsSuperUser);
|
|
@ -1,89 +1,102 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Femto.Api.Auth;
|
||||
using Femto.Modules.Auth.Application.Dto;
|
||||
using Femto.Modules.Auth.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Femto.Api.Sessions;
|
||||
|
||||
internal record SessionInfo(string? SessionId, Guid? UserId);
|
||||
|
||||
internal static class HttpContextSessionExtensions
|
||||
{
|
||||
public static void SetSession(this HttpContext httpContext, SessionDto sessionDto, UserInfo user, ILogger logger)
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
var cookieSettings = httpContext.RequestServices.GetService<IOptions<CookieSettings>>();
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
public static SessionInfo GetSessionInfo(this HttpContext httpContext)
|
||||
{
|
||||
var sessionId = httpContext.Request.Cookies["sid"];
|
||||
|
||||
var secure = cookieSettings?.Value.Secure ?? true;
|
||||
var sameSite = cookieSettings?.Value.SameSite ?? SameSiteMode.Strict;
|
||||
var domain = cookieSettings?.Value.Domain;
|
||||
var expires = sessionDto.Expires;
|
||||
var userJson = httpContext.Request.Cookies["user"];
|
||||
|
||||
UserInfo? user = null;
|
||||
if (userJson is not null)
|
||||
{
|
||||
user = JsonSerializer.Deserialize<UserInfo>(userJson, JsonOptions);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}",
|
||||
secure,
|
||||
sameSite,
|
||||
domain,
|
||||
expires
|
||||
);
|
||||
return new SessionInfo(sessionId, user?.Id);
|
||||
}
|
||||
|
||||
httpContext.Response.Cookies.Append(
|
||||
"session",
|
||||
sessionDto.SessionId,
|
||||
public static void SetSession(this HttpContext context, Session session, UserInfo user)
|
||||
{
|
||||
var cookieSettings = context.RequestServices.GetRequiredService<
|
||||
IOptions<CookieSettings>
|
||||
>();
|
||||
|
||||
context.Response.Cookies.Append(
|
||||
"sid",
|
||||
session.Id,
|
||||
new CookieOptions
|
||||
{
|
||||
Path = "/",
|
||||
IsEssential = true,
|
||||
Domain = domain,
|
||||
Domain = cookieSettings.Value.Domain,
|
||||
HttpOnly = true,
|
||||
Secure = secure,
|
||||
SameSite = sameSite,
|
||||
Expires = expires,
|
||||
Secure = cookieSettings.Value.Secure,
|
||||
SameSite = cookieSettings.Value.SameSite,
|
||||
Expires = session.Expires,
|
||||
}
|
||||
);
|
||||
|
||||
httpContext.Response.Cookies.Append(
|
||||
context.Response.Cookies.Append(
|
||||
"user",
|
||||
JsonSerializer.Serialize(
|
||||
user,
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
}
|
||||
),
|
||||
JsonSerializer.Serialize(user, JsonOptions),
|
||||
new CookieOptions
|
||||
{
|
||||
Domain = domain,
|
||||
Path = "/",
|
||||
Domain = cookieSettings.Value.Domain,
|
||||
IsEssential = true,
|
||||
Secure = secure,
|
||||
SameSite = sameSite,
|
||||
Expires = sessionDto.Expires,
|
||||
Secure = cookieSettings.Value.Secure,
|
||||
SameSite = cookieSettings.Value.SameSite,
|
||||
Expires = session.Expires,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public static void DeleteSession(this HttpContext httpContext)
|
||||
{
|
||||
var cookieSettings = httpContext.RequestServices.GetService<IOptions<CookieSettings>>();
|
||||
|
||||
var secure = cookieSettings?.Value.Secure ?? true;
|
||||
var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified;
|
||||
var domain = cookieSettings?.Value.Domain;
|
||||
|
||||
httpContext.Response.Cookies.Delete("session", new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Domain = domain,
|
||||
IsEssential = true,
|
||||
Secure = secure,
|
||||
SameSite = sameSite,
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
});
|
||||
httpContext.Response.Cookies.Delete("user", new CookieOptions
|
||||
{
|
||||
Domain = domain,
|
||||
IsEssential = true,
|
||||
Secure = secure,
|
||||
SameSite = sameSite,
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
});
|
||||
var cookieSettings = httpContext.RequestServices.GetRequiredService<
|
||||
IOptions<CookieSettings>
|
||||
>();
|
||||
|
||||
httpContext.Response.Cookies.Delete(
|
||||
"sid",
|
||||
new CookieOptions
|
||||
{
|
||||
Path = "/",
|
||||
HttpOnly = true,
|
||||
Domain = cookieSettings.Value.Domain,
|
||||
IsEssential = true,
|
||||
Secure = cookieSettings.Value.Secure,
|
||||
SameSite = cookieSettings.Value.SameSite,
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
}
|
||||
);
|
||||
|
||||
httpContext.Response.Cookies.Delete(
|
||||
"user",
|
||||
new CookieOptions
|
||||
{
|
||||
Path = "/",
|
||||
Domain = cookieSettings.Value.Domain,
|
||||
IsEssential = true,
|
||||
Secure = cookieSettings.Value.Secure,
|
||||
SameSite = cookieSettings.Value.SameSite,
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue