Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions Streetwriters.Identity/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,116 @@ public async Task<IActionResult> UpdateAccount([FromForm] UpdateUserForm form)
return BadRequest("Invalid type.");
}

[HttpGet("sessions")]
public async Task<IActionResult> GetActiveSessions()
{
var client = Clients.FindClientById(User.FindFirstValue("client_id"));
if (client == null) return BadRequest("Invalid client_id.");

var user = await UserManager.GetUserAsync(User) ?? throw new Exception("User not found.");
if (!await UserService.IsUserValidAsync(UserManager, user, client.Id))
return BadRequest($"Unable to find user with ID '{user.Id}'.");

var jti = User.FindFirstValue("jti");

var refreshTokens = await PersistedGrantStore.GetAllAsync(new PersistedGrantFilter
{
ClientId = client.Id,
SubjectId = user.Id.ToString(),
Type = PersistedGrantTypes.RefreshToken
});

var now = DateTime.UtcNow;
var activeSessions = refreshTokens
.Where(grant => grant.Expiration == null || grant.Expiration > now)
.Select(grant =>
{
var session = new DeviceSession
{
SessionKey = grant.Key,
CreatedAt = grant.CreationTime
};

try
{
using var refreshData = JsonDocument.Parse(grant.Data);

if (refreshData.RootElement.TryGetProperty("AccessToken", out var accessTokenElement) &&
accessTokenElement.TryGetProperty("Claims", out var claimsElement))
{
foreach (var claim in claimsElement.EnumerateArray())
{
if (claim.TryGetProperty("Type", out var typeElement) &&
claim.TryGetProperty("Value", out var valueElement))
{
var type = typeElement.GetString();
var value = valueElement.GetString();

switch (type)
{
case "jti":
session.IsCurrentDevice = jti != null && value == jti;
break;
case "device_browser":
session.Browser = value;
break;
case "device_platform":
session.Platform = value;
break;
}
}
}
}
}
catch
{
logger.LogWarning("Failed to parse device info from refresh token data for session {SessionId}", grant.Key);

}

return session;
})
.OrderByDescending(session => session.CreatedAt)


.ToList(); return Ok(activeSessions);
}

[HttpPost("clear-session")]
public async Task<IActionResult> LogoutSession([FromForm] string sessionKey)
{
var client = Clients.FindClientById(User.FindFirstValue("client_id"));
if (client == null) return BadRequest("Invalid client_id.");

var user = await UserManager.GetUserAsync(User) ?? throw new Exception("User not found.");
if (!await UserService.IsUserValidAsync(UserManager, user, client.Id))
return BadRequest($"Unable to find user with ID '{user.Id}'.");

var grants = await PersistedGrantStore.GetAllAsync(new PersistedGrantFilter
{
ClientId = client.Id,
SubjectId = user.Id.ToString()
});

var grantToRemove = grants.FirstOrDefault(g => g.Key == sessionKey);
if (grantToRemove == null)
return NotFound("Session not found.");

var currentJti = User.FindFirstValue("jti");
if (currentJti != null && grantToRemove.Data.Contains(currentJti))
return BadRequest("Cannot logout current session. Use logout endpoint instead.");

await PersistedGrantStore.RemoveAsync(grantToRemove.Key);

var removedKeys = new List<string> { grantToRemove.Key };
await WampServers.NotesnookServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
await WampServers.MessengerServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
await WampServers.SubscriptionServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
await SendLogoutMessageAsync(user.Id.ToString(), "Session revoked.");

return Ok();
}

[HttpPost("sessions/clear")]
public async Task<IActionResult> ClearUserSessions([FromQuery] bool all, [FromForm] string? refresh_token)
{
Expand Down
32 changes: 32 additions & 0 deletions Streetwriters.Identity/Models/DeviceSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
This file is part of the Notesnook Sync Server project (https://notesnook.com/)

Copyright (C) 2023 Streetwriters (Private) Limited

This program is free software: you can redistribute it and/or modify
it under the terms of the Affero GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Affero GNU General Public License for more details.

You should have received a copy of the Affero GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

using System;

namespace Streetwriters.Identity.Models
{
public class DeviceSession
{
public string SessionKey { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public bool IsCurrentDevice { get; set; }
public string? Browser { get; set; }
public string? Platform { get; set; }
}
}
42 changes: 39 additions & 3 deletions Streetwriters.Identity/Services/ProfileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,22 @@ You should have received a copy of the Affero GNU General Public License
using IdentityModel;
using IdentityServer4.Models;
using IdentityServer4.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Streetwriters.Common.Enums;
using Ng.Services;
using Streetwriters.Common.Models;
using Streetwriters.Data.Repositories;

namespace Streetwriters.Identity.Services
{
public class ProfileService : IProfileService
{
protected UserManager<User> UserManager { get; set; }
private IHttpContextAccessor HttpContextAccessor { get; set; }

public ProfileService(UserManager<User> userManager)
public ProfileService(UserManager<User> userManager, IHttpContextAccessor httpContextAccessor)
{
UserManager = userManager;
HttpContextAccessor = httpContextAccessor;
}

public async Task GetProfileDataAsync(ProfileDataRequestContext context)
Expand All @@ -51,6 +53,40 @@ public async Task GetProfileDataAsync(ProfileDataRequestContext context)

context.IssuedClaims.AddRange(roles.Select((r) => new Claim(JwtClaimTypes.Role, r)));
context.IssuedClaims.AddRange(claims);

var httpContext = HttpContextAccessor.HttpContext;
if (httpContext == null) return;

var userAgentHeader = httpContext.Request.Headers.UserAgent.ToString();
if (string.IsNullOrEmpty(userAgentHeader)) return;

var userAgentService = new UserAgentService();
var ua = userAgentService.Parse(userAgentHeader);
string? browser = null;
if (userAgentHeader.Contains("Electron/", StringComparison.OrdinalIgnoreCase))
{
var electronMatch = System.Text.RegularExpressions.Regex.Match(
userAgentHeader,
@"Electron/([\d.]+)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase
);
browser = electronMatch.Success
? $"Electron {electronMatch.Groups[1].Value}"
: "Electron";
}
else if (!string.IsNullOrEmpty(ua.Browser))
{
browser = $"{ua.Browser} {ua.BrowserVersion}";
}

if (!string.IsNullOrEmpty(browser))
{
context.IssuedClaims.Add(new Claim("device_browser", browser));
}
if (!string.IsNullOrEmpty(ua.Platform))
{
context.IssuedClaims.Add(new Claim("device_platform", ua.Platform));
}
}

public Task IsActiveAsync(IsActiveContext context)
Expand Down