Skip to content

Commit 5536c05

Browse files
authored
Add session management with revocation capabilities and security fix (#822)
### Summary & Motivation Add user session management functionality allowing users to view and revoke active sessions across all devices and accounts. Sessions are queried across all accounts for the user's email address using unfiltered repository methods and displayed with device information, browser details, IP address, and account name in a modal dialog accessible from the user menu. Each session can be individually revoked with confirmation dialogs to prevent accidental sign-outs. Session revocation validates ownership by email instead of user ID, ensuring users can revoke sessions across all their accounts. The implementation includes a critical security fix discovered during development: - **Fix session lifetime extension vulnerability**: Switching accounts previously created a new 90-day session, allowing infinite session extension. Current session is now revoked when switching accounts, and the new session preserves the original expiry date through a computed `ExpiresAt` property on the Session aggregate and token generation overloads that accept custom expiry parameters. Backend changes include session revocation endpoints with tests, repository methods for unfiltered session queries, `SwitchTenant` added to `SessionRevokedReason` enum, and `RefreshTokenGenerator.ValidForHours` made public as the single source of truth. Frontend changes include SessionsModal component with device type detection, user agent parsing, Smart Date formatting, confirmation dialogs, and E2E tests covering session viewing, individual revocation, and cross-account scenarios. ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents a806cef + afff316 commit 5536c05

File tree

20 files changed

+937
-31
lines changed

20 files changed

+937
-31
lines changed

application/account-management/Api/Endpoints/AuthenticationEndpoints.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands;
55
using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain;
66
using PlatformPlatform.SharedKernel.ApiResults;
7+
using PlatformPlatform.SharedKernel.Authentication.TokenGeneration;
78
using PlatformPlatform.SharedKernel.Endpoints;
89

910
namespace PlatformPlatform.AccountManagement.Api.Endpoints;
@@ -40,6 +41,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
4041
=> await mediator.Send(query)
4142
).Produces<UserSessionsResponse>();
4243

44+
group.MapDelete("/sessions/{id}", async Task<ApiResult> (SessionId id, IMediator mediator)
45+
=> await mediator.Send(new RevokeSessionCommand { Id = id })
46+
);
47+
4348
// Note: This endpoint must be called with the refresh token as Bearer token in the Authorization header
4449
routes.MapPost("/internal-api/account-management/authentication/refresh-authentication-tokens", async Task<ApiResult> (IMediator mediator)
4550
=> await mediator.Send(new RefreshAuthenticationTokensCommand())
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using JetBrains.Annotations;
2+
using PlatformPlatform.AccountManagement.Features.Authentication.Domain;
3+
using PlatformPlatform.AccountManagement.Features.Users.Domain;
4+
using PlatformPlatform.SharedKernel.Authentication.TokenGeneration;
5+
using PlatformPlatform.SharedKernel.Cqrs;
6+
using PlatformPlatform.SharedKernel.ExecutionContext;
7+
using PlatformPlatform.SharedKernel.Telemetry;
8+
9+
namespace PlatformPlatform.AccountManagement.Features.Authentication.Commands;
10+
11+
[PublicAPI]
12+
public sealed record RevokeSessionCommand : ICommand, IRequest<Result>
13+
{
14+
[JsonIgnore]
15+
public SessionId Id { get; init; } = null!;
16+
}
17+
18+
public sealed class RevokeSessionHandler(
19+
ISessionRepository sessionRepository,
20+
IUserRepository userRepository,
21+
IExecutionContext executionContext,
22+
ITelemetryEventsCollector events,
23+
TimeProvider timeProvider
24+
) : IRequestHandler<RevokeSessionCommand, Result>
25+
{
26+
public async Task<Result> Handle(RevokeSessionCommand command, CancellationToken cancellationToken)
27+
{
28+
var userEmail = executionContext.UserInfo.Email!;
29+
30+
var session = await sessionRepository.GetByIdUnfilteredAsync(command.Id, cancellationToken);
31+
if (session is null)
32+
{
33+
return Result.NotFound($"Session with id '{command.Id}' not found.");
34+
}
35+
36+
var sessionUser = await userRepository.GetByIdUnfilteredAsync(session.UserId, cancellationToken);
37+
if (sessionUser?.Email != userEmail)
38+
{
39+
return Result.Forbidden("You can only revoke your own sessions.");
40+
}
41+
42+
if (session.IsRevoked)
43+
{
44+
return Result.BadRequest($"Session with id '{command.Id}' is already revoked.");
45+
}
46+
47+
session.Revoke(timeProvider.GetUtcNow(), SessionRevokedReason.Revoked);
48+
sessionRepository.Update(session);
49+
50+
events.CollectEvent(new SessionRevoked(SessionRevokedReason.Revoked));
51+
52+
return Result.Success();
53+
}
54+
}

application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,33 @@ public async Task<Result> Handle(SwitchTenantCommand command, CancellationToken
4747
await CopyProfileDataFromCurrentUser(targetUser, cancellationToken);
4848
}
4949

50+
var currentSessionId = executionContext.UserInfo.SessionId;
51+
var currentSession = await sessionRepository.GetByIdUnfilteredAsync(currentSessionId!, cancellationToken);
52+
if (currentSession is null)
53+
{
54+
logger.LogWarning("Current session '{SessionId}' not found", currentSessionId);
55+
return Result.Unauthorized("Current session not found.");
56+
}
57+
58+
if (currentSession.IsRevoked)
59+
{
60+
logger.LogWarning("Current session '{SessionId}' is already revoked", currentSessionId);
61+
return Result.Unauthorized("Session has been revoked.");
62+
}
63+
64+
var now = timeProvider.GetUtcNow();
65+
currentSession.Revoke(now, SessionRevokedReason.SwitchTenant);
66+
sessionRepository.Update(currentSession);
67+
events.CollectEvent(new SessionRevoked(SessionRevokedReason.SwitchTenant));
68+
5069
var userAgent = httpContextAccessor.HttpContext?.Request.Headers.UserAgent.ToString() ?? string.Empty;
5170
var ipAddress = executionContext.ClientIpAddress;
5271

5372
var session = Session.Create(targetUser.TenantId, targetUser.Id, userAgent, ipAddress);
5473
await sessionRepository.AddAsync(session, cancellationToken);
5574

5675
var userInfo = await userInfoFactory.CreateUserInfoAsync(targetUser, cancellationToken, session.Id);
57-
authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti);
76+
authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti, currentSession.ExpiresAt);
5877

5978
events.CollectEvent(new SessionCreated(session.Id));
6079
events.CollectEvent(new TenantSwitched(executionContext.TenantId!, command.TenantId, targetUser.Id));

application/account-management/Core/Features/Authentication/Domain/Session.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ private Session(TenantId tenantId, UserId userId, DeviceType deviceType, string
4242

4343
public bool IsRevoked => RevokedAt is not null;
4444

45+
public DateTimeOffset ExpiresAt => CreatedAt.AddHours(RefreshTokenGenerator.ValidForHours);
46+
4547
public TenantId TenantId { get; }
4648

4749
public static Session Create(TenantId tenantId, UserId userId, string userAgent, IPAddress ipAddress)

application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ public interface ISessionRepository : ICrudRepository<Session, SessionId>
1515
Task<Session?> GetByIdUnfilteredAsync(SessionId sessionId, CancellationToken cancellationToken);
1616

1717
Task<Session[]> GetActiveSessionsForUserAsync(UserId userId, CancellationToken cancellationToken);
18+
19+
/// <summary>
20+
/// Retrieves all active sessions for multiple users across all tenants without applying query filters.
21+
/// This method should only be used in the Sessions dialog where users need to see all sessions for their email.
22+
/// </summary>
23+
Task<Session[]> GetActiveSessionsForUsersUnfilteredAsync(UserId[] userIds, CancellationToken cancellationToken);
1824
}
1925

2026
public sealed class SessionRepository(AccountManagementDbContext accountManagementDbContext)
@@ -32,4 +38,13 @@ public async Task<Session[]> GetActiveSessionsForUserAsync(UserId userId, Cancel
3238
.ToArrayAsync(cancellationToken);
3339
return sessions.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ToArray();
3440
}
41+
42+
public async Task<Session[]> GetActiveSessionsForUsersUnfilteredAsync(UserId[] userIds, CancellationToken cancellationToken)
43+
{
44+
var sessions = await DbSet
45+
.IgnoreQueryFilters()
46+
.Where(s => userIds.AsEnumerable().Contains(s.UserId) && s.RevokedAt == null)
47+
.ToArrayAsync(cancellationToken);
48+
return sessions.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ToArray();
49+
}
3550
}

application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@ public enum DeviceType
1717
public enum SessionRevokedReason
1818
{
1919
LoggedOut,
20-
ReplayAttackDetected
20+
Revoked,
21+
ReplayAttackDetected,
22+
SwitchTenant
2123
}

application/account-management/Core/Features/Authentication/Queries/GetUserSessions.cs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using JetBrains.Annotations;
22
using PlatformPlatform.AccountManagement.Features.Authentication.Domain;
3+
using PlatformPlatform.AccountManagement.Features.Tenants.Domain;
4+
using PlatformPlatform.AccountManagement.Features.Users.Domain;
35
using PlatformPlatform.SharedKernel.Authentication.TokenGeneration;
46
using PlatformPlatform.SharedKernel.Cqrs;
57
using PlatformPlatform.SharedKernel.ExecutionContext;
@@ -20,18 +22,30 @@ public sealed record UserSessionInfo(
2022
string UserAgent,
2123
string IpAddress,
2224
DateTimeOffset LastActivityAt,
23-
bool IsCurrent
25+
bool IsCurrent,
26+
string TenantName
2427
);
2528

26-
public sealed class GetUserSessionsHandler(ISessionRepository sessionRepository, IExecutionContext executionContext)
27-
: IRequestHandler<GetUserSessionsQuery, Result<UserSessionsResponse>>
29+
public sealed class GetUserSessionsHandler(
30+
ISessionRepository sessionRepository,
31+
IUserRepository userRepository,
32+
ITenantRepository tenantRepository,
33+
IExecutionContext executionContext
34+
) : IRequestHandler<GetUserSessionsQuery, Result<UserSessionsResponse>>
2835
{
2936
public async Task<Result<UserSessionsResponse>> Handle(GetUserSessionsQuery query, CancellationToken cancellationToken)
3037
{
31-
var userId = executionContext.UserInfo.Id!;
38+
var userEmail = executionContext.UserInfo.Email!;
3239
var currentSessionId = executionContext.UserInfo.SessionId;
3340

34-
var sessions = await sessionRepository.GetActiveSessionsForUserAsync(userId, cancellationToken);
41+
var users = await userRepository.GetUsersByEmailUnfilteredAsync(userEmail, cancellationToken);
42+
var userIds = users.Select(u => u.Id).ToArray();
43+
44+
var sessions = await sessionRepository.GetActiveSessionsForUsersUnfilteredAsync(userIds, cancellationToken);
45+
46+
var tenantIds = sessions.Select(s => s.TenantId).Distinct().ToArray();
47+
var tenants = await tenantRepository.GetByIdsAsync(tenantIds, cancellationToken);
48+
var tenantLookup = tenants.ToDictionary(t => t.Id, t => t.Name);
3549

3650
var sessionInfos = sessions.Select(s => new UserSessionInfo(
3751
s.Id,
@@ -40,7 +54,8 @@ public async Task<Result<UserSessionsResponse>> Handle(GetUserSessionsQuery quer
4054
s.UserAgent,
4155
s.IpAddress,
4256
s.ModifiedAt ?? s.CreatedAt,
43-
currentSessionId is not null && s.Id == currentSessionId
57+
currentSessionId is not null && s.Id == currentSessionId,
58+
tenantLookup.GetValueOrDefault(s.TenantId) ?? string.Empty
4459
)
4560
).ToArray();
4661

application/account-management/Tests/Authentication/GetUserSessionsTests.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using PlatformPlatform.AccountManagement.Features.Authentication.Domain;
55
using PlatformPlatform.AccountManagement.Features.Authentication.Queries;
66
using PlatformPlatform.SharedKernel.Authentication.TokenGeneration;
7+
using PlatformPlatform.SharedKernel.Domain;
78
using PlatformPlatform.SharedKernel.Tests;
89
using PlatformPlatform.SharedKernel.Tests.Persistence;
910
using Xunit;
@@ -75,6 +76,34 @@ public async Task GetUserSessions_ShouldNotReturnOtherUserSessions()
7576
responseBody.Sessions.Should().Contain(s => s.Id == DatabaseSeeder.Tenant1OwnerSession.Id);
7677
}
7778

79+
[Fact]
80+
public async Task GetUserSessions_ShouldReturnSessionsAcrossAllTenants()
81+
{
82+
// Arrange
83+
var tenant2Name = "Tenant 2";
84+
var tenant2Id = InsertTenant(tenant2Name);
85+
var user2Id = UserId.NewId();
86+
InsertUser(tenant2Id, user2Id, DatabaseSeeder.Tenant1Owner.Email);
87+
var tenant2SessionId = InsertSession(tenant2Id, user2Id);
88+
89+
// Act
90+
var response = await AuthenticatedOwnerHttpClient.GetAsync("/api/account-management/authentication/sessions");
91+
92+
// Assert
93+
response.StatusCode.Should().Be(HttpStatusCode.OK);
94+
var responseBody = await response.DeserializeResponse<UserSessionsResponse>();
95+
responseBody.Should().NotBeNull();
96+
responseBody.Sessions.Length.Should().Be(2);
97+
responseBody.Sessions.Should().Contain(s => s.Id == DatabaseSeeder.Tenant1OwnerSession.Id);
98+
responseBody.Sessions.Should().Contain(s => s.Id == new SessionId(tenant2SessionId));
99+
100+
var tenant1Session = responseBody.Sessions.Single(s => s.Id == DatabaseSeeder.Tenant1OwnerSession.Id);
101+
tenant1Session.TenantName.Should().Be(DatabaseSeeder.Tenant1.Name);
102+
103+
var tenant2Session = responseBody.Sessions.Single(s => s.Id == new SessionId(tenant2SessionId));
104+
tenant2Session.TenantName.Should().Be(tenant2Name);
105+
}
106+
78107
[Fact]
79108
public async Task GetUserSessions_ShouldNotReturnRevokedSessions()
80109
{
@@ -94,6 +123,45 @@ public async Task GetUserSessions_ShouldNotReturnRevokedSessions()
94123
responseBody.Sessions.Should().Contain(s => s.Id == DatabaseSeeder.Tenant1OwnerSession.Id);
95124
}
96125

126+
private long InsertTenant(string name)
127+
{
128+
var tenantId = TenantId.NewId().Value;
129+
var now = TimeProvider.System.GetUtcNow();
130+
131+
Connection.Insert("Tenants", [
132+
("Id", tenantId),
133+
("CreatedAt", now),
134+
("ModifiedAt", null),
135+
("Name", name),
136+
("State", "Active"),
137+
("Logo", """{"Url":null,"Version":0}""")
138+
]
139+
);
140+
141+
return tenantId;
142+
}
143+
144+
private void InsertUser(long tenantId, UserId userId, string email)
145+
{
146+
var now = TimeProvider.System.GetUtcNow();
147+
148+
Connection.Insert("Users", [
149+
("TenantId", tenantId),
150+
("Id", userId.ToString()),
151+
("CreatedAt", now),
152+
("ModifiedAt", null),
153+
("Email", email),
154+
("EmailConfirmed", true),
155+
("FirstName", "Test"),
156+
("LastName", "User"),
157+
("Title", null),
158+
("Avatar", """{"Url":null,"Version":0,"IsGravatar":false}"""),
159+
("Role", "Owner"),
160+
("Locale", "en-US")
161+
]
162+
);
163+
}
164+
97165
private string InsertSession(long tenantId, string userId, bool isRevoked = false)
98166
{
99167
var sessionId = SessionId.NewId().ToString();

0 commit comments

Comments
 (0)