diff --git a/DysonNetwork.Sphere/Account/AccountController.cs b/DysonNetwork.Sphere/Account/AccountController.cs index a3920d7..2204ff9 100644 --- a/DysonNetwork.Sphere/Account/AccountController.cs +++ b/DysonNetwork.Sphere/Account/AccountController.cs @@ -4,7 +4,6 @@ using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; using NodaTime; namespace DysonNetwork.Sphere.Account; @@ -15,8 +14,8 @@ public class AccountController( AppDatabase db, FileService fs, AuthService auth, - MagicSpellService spells, - IMemoryCache memCache + AccountService accounts, + MagicSpellService spells ) : ControllerBase { [HttpGet("{name}")] @@ -138,7 +137,7 @@ public class AccountController( if (request.Nick is not null) account.Nick = request.Nick; if (request.Language is not null) account.Language = request.Language; - memCache.Remove($"user_${account.Id}"); + await accounts.PurgeAccountCache(account); await db.SaveChangesAsync(); return account; @@ -199,7 +198,7 @@ public class AccountController( db.Update(profile); await db.SaveChangesAsync(); - memCache.Remove($"user_${userId}"); + await accounts.PurgeAccountCache(currentUser); return profile; } diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs index 9170da7..ba8ef5f 100644 --- a/DysonNetwork.Sphere/Account/AccountService.cs +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -1,12 +1,23 @@ using Casbin; using DysonNetwork.Sphere.Permission; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using NodaTime; namespace DysonNetwork.Sphere.Account; -public class AccountService(AppDatabase db, PermissionService pm) +public class AccountService(AppDatabase db, PermissionService pm, IMemoryCache cache) { + public async Task PurgeAccountCache(Account account) + { + var sessions = await db.AuthSessions.Where(e => e.Account.Id == account.Id).Select(e => e.Id) + .ToListAsync(); + foreach (var session in sessions) + { + cache.Remove($"dyn_auth_{session}"); + } + } + public async Task LookupAccount(string probe) { var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync(); diff --git a/DysonNetwork.Sphere/Account/MagicSpellService.cs b/DysonNetwork.Sphere/Account/MagicSpellService.cs index 48aecee..7e4516c 100644 --- a/DysonNetwork.Sphere/Account/MagicSpellService.cs +++ b/DysonNetwork.Sphere/Account/MagicSpellService.cs @@ -45,6 +45,8 @@ public class MagicSpellService(AppDatabase db, EmailService email, ILogger Audiences { get; set; } = new(); public List Scopes { get; set; } = new(); } @@ -52,6 +53,7 @@ public class AuthController( Account = account, ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)), StepTotal = 1, + Platform = request.Platform, Audiences = request.Audiences, Scopes = request.Scopes, IpAddress = ipAddress, @@ -125,6 +127,8 @@ public class AuthController( } catch { + challenge.FailedAttempts++; + await db.SaveChangesAsync(); return BadRequest(); } diff --git a/DysonNetwork.Sphere/Auth/Session.cs b/DysonNetwork.Sphere/Auth/Session.cs index fdc95da..d44263a 100644 --- a/DysonNetwork.Sphere/Auth/Session.cs +++ b/DysonNetwork.Sphere/Auth/Session.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using Microsoft.VisualStudio.Web.CodeGenerators.Mvc.Templates.BlazorIdentity.Pages; using NodaTime; namespace DysonNetwork.Sphere.Auth; @@ -8,6 +9,7 @@ namespace DysonNetwork.Sphere.Auth; public class Session : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(1024)] public string? Label { get; set; } public Instant? LastGrantedAt { get; set; } public Instant? ExpiredAt { get; set; } @@ -15,6 +17,23 @@ public class Session : ModelBase [JsonIgnore] public Challenge Challenge { get; set; } = null!; } +public enum ChallengeType +{ + Login, + OAuth +} + +public enum ChallengePlatform +{ + Unidentified, + Web, + Ios, + Android, + MacOs, + Windows, + Linux +} + public class Challenge : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); @@ -22,6 +41,8 @@ public class Challenge : ModelBase public int StepRemain { get; set; } public int StepTotal { get; set; } public int FailedAttempts { get; set; } + public ChallengePlatform Platform { get; set; } = ChallengePlatform.Unidentified; + public ChallengeType Type { get; set; } = ChallengeType.Login; [Column(TypeName = "jsonb")] public List BlacklistFactors { get; set; } = new(); [Column(TypeName = "jsonb")] public List Audiences { get; set; } = new(); [Column(TypeName = "jsonb")] public List Scopes { get; set; } = new(); diff --git a/DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs b/DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs index effee0c..fda47b8 100644 --- a/DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs +++ b/DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs @@ -7,29 +7,30 @@ public class UserInfoMiddleware(RequestDelegate next, IMemoryCache cache) { public async Task InvokeAsync(HttpContext context, AppDatabase db) { - var userIdClaim = context.User.FindFirst("user_id")?.Value; - if (userIdClaim is not null && long.TryParse(userIdClaim, out var userId)) + var sessionIdClaim = context.User.FindFirst("session_id")?.Value; + if (sessionIdClaim is not null && Guid.TryParse(sessionIdClaim, out var sessionId)) { - if (!cache.TryGetValue($"user_{userId}", out Account.Account? user)) + if (!cache.TryGetValue($"dyn_auth_{sessionId}", out Session? session)) { - user = await db.Accounts - .Include(e => e.Profile) - .Include(e => e.Profile.Picture) - .Include(e => e.Profile.Background) - .Where(e => e.Id == userId) + session = await db.AuthSessions + .Include(e => e.Challenge) + .Include(e => e.Account) + .Include(e => e.Account.Profile) + .Include(e => e.Account.Profile.Picture) + .Include(e => e.Account.Profile.Background) + .Where(e => e.Id == sessionId) .FirstOrDefaultAsync(); - if (user is not null) + if (session is not null) { - cache.Set($"user_{userId}", user, TimeSpan.FromMinutes(10)); + cache.Set($"dyn_auth_{sessionId}", session, TimeSpan.FromHours(1)); } } - if (user is not null) + if (session is not null) { - context.Items["CurrentUser"] = user; - var prefix = user.IsSuperuser ? "super:" : ""; - context.Items["CurrentIdentity"] = $"{prefix}{userId}"; + context.Items["CurrentUser"] = session.Account; + context.Items["CurrentSession"] = session; } } diff --git a/DysonNetwork.Sphere/Connection/WebSocketController.cs b/DysonNetwork.Sphere/Connection/WebSocketController.cs index 8133610..5aab330 100644 --- a/DysonNetwork.Sphere/Connection/WebSocketController.cs +++ b/DysonNetwork.Sphere/Connection/WebSocketController.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using System.Net.WebSockets; -using DysonNetwork.Sphere.Account; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; @@ -11,13 +10,10 @@ namespace DysonNetwork.Sphere.Connection; [Route("/ws")] public class WebSocketController : ControllerBase { - // Concurrent dictionary to store active WebSocket connections. - // Key: Tuple (AccountId, DeviceId); Value: WebSocket and CancellationTokenSource private static readonly ConcurrentDictionary< (long AccountId, string DeviceId), (WebSocket Socket, CancellationTokenSource Cts) - > ActiveConnections = - new ConcurrentDictionary<(long, string), (WebSocket, CancellationTokenSource)>(); + > ActiveConnections = new(); [Route("/ws")] [Authorize] @@ -26,18 +22,18 @@ public class WebSocketController : ControllerBase { if (HttpContext.WebSockets.IsWebSocketRequest) { - // Get AccountId from HttpContext - if ( - !HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue) - || currentUserValue is not Account.Account currentUser - ) + HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); + HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); + if (currentUserValue is not Account.Account currentUser || + currentSessionValue is not Auth.Session currentSession) { HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } - long accountId = currentUser.Id; - // Verify deviceId + var accountId = currentUser.Id; + var deviceId = currentSession.Challenge.DeviceId; + if (string.IsNullOrEmpty(deviceId)) { HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; @@ -45,14 +41,11 @@ public class WebSocketController : ControllerBase } using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); - // Create a CancellationTokenSource for this connection var cts = new CancellationTokenSource(); var connectionKey = (accountId, deviceId); - // Add the connection to the active connections dictionary if (!ActiveConnections.TryAdd(connectionKey, (webSocket, cts))) { - // Failed to add await webSocket.CloseAsync( WebSocketCloseStatus.InternalServerError, "Failed to establish connection.", @@ -71,7 +64,6 @@ public class WebSocketController : ControllerBase } finally { - // Connection is closed, remove it from the active connections dictionary ActiveConnections.TryRemove(connectionKey, out _); cts.Dispose(); } @@ -88,25 +80,21 @@ public class WebSocketController : ControllerBase CancellationToken cancellationToken ) { - // Buffer for receiving messages. var buffer = new byte[1024 * 4]; try { - // We don't handle receiving data, so we ignore the return. var receiveResult = await webSocket.ReceiveAsync( new ArraySegment(buffer), cancellationToken ); while (!receiveResult.CloseStatus.HasValue) { - // Keep connection alive and wait for close requests receiveResult = await webSocket.ReceiveAsync( new ArraySegment(buffer), cancellationToken ); } - // Close connection await webSocket.CloseAsync( receiveResult.CloseStatus.Value, receiveResult.CloseStatusDescription, @@ -144,5 +132,4 @@ public class WebSocketController : ControllerBase ); } } -} - +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Migrations/20250429115700_InitialMigration.Designer.cs b/DysonNetwork.Sphere/Migrations/20250430163514_InitialMigration.Designer.cs similarity index 99% rename from DysonNetwork.Sphere/Migrations/20250429115700_InitialMigration.Designer.cs rename to DysonNetwork.Sphere/Migrations/20250430163514_InitialMigration.Designer.cs index 29109ef..8679b25 100644 --- a/DysonNetwork.Sphere/Migrations/20250429115700_InitialMigration.Designer.cs +++ b/DysonNetwork.Sphere/Migrations/20250430163514_InitialMigration.Designer.cs @@ -15,7 +15,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace DysonNetwork.Sphere.Migrations { [DbContext(typeof(AppDatabase))] - [Migration("20250429115700_InitialMigration")] + [Migration("20250430163514_InitialMigration")] partial class InitialMigration { /// @@ -508,6 +508,10 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("character varying(1024)") .HasColumnName("nonce"); + b.Property("Platform") + .HasColumnType("integer") + .HasColumnName("platform"); + b.Property>("Scopes") .IsRequired() .HasColumnType("jsonb") @@ -521,6 +525,10 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("integer") .HasColumnName("step_total"); + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); @@ -566,6 +574,11 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("expired_at"); + b.Property("Label") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("label"); + b.Property("LastGrantedAt") .HasColumnType("timestamp with time zone") .HasColumnName("last_granted_at"); diff --git a/DysonNetwork.Sphere/Migrations/20250429115700_InitialMigration.cs b/DysonNetwork.Sphere/Migrations/20250430163514_InitialMigration.cs similarity index 99% rename from DysonNetwork.Sphere/Migrations/20250429115700_InitialMigration.cs rename to DysonNetwork.Sphere/Migrations/20250430163514_InitialMigration.cs index ccce33d..5c4d309 100644 --- a/DysonNetwork.Sphere/Migrations/20250429115700_InitialMigration.cs +++ b/DysonNetwork.Sphere/Migrations/20250430163514_InitialMigration.cs @@ -171,6 +171,8 @@ namespace DysonNetwork.Sphere.Migrations step_remain = table.Column(type: "integer", nullable: false), step_total = table.Column(type: "integer", nullable: false), failed_attempts = table.Column(type: "integer", nullable: false), + platform = table.Column(type: "integer", nullable: false), + type = table.Column(type: "integer", nullable: false), blacklist_factors = table.Column>(type: "jsonb", nullable: false), audiences = table.Column>(type: "jsonb", nullable: false), scopes = table.Column>(type: "jsonb", nullable: false), @@ -326,6 +328,7 @@ namespace DysonNetwork.Sphere.Migrations columns: table => new { id = table.Column(type: "uuid", nullable: false), + label = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), last_granted_at = table.Column(type: "timestamp with time zone", nullable: true), expired_at = table.Column(type: "timestamp with time zone", nullable: true), account_id = table.Column(type: "bigint", nullable: false), diff --git a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs index 5c6962b..040a9f9 100644 --- a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs @@ -505,6 +505,10 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("character varying(1024)") .HasColumnName("nonce"); + b.Property("Platform") + .HasColumnType("integer") + .HasColumnName("platform"); + b.Property>("Scopes") .IsRequired() .HasColumnType("jsonb") @@ -518,6 +522,10 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("integer") .HasColumnName("step_total"); + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); @@ -563,6 +571,11 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("expired_at"); + b.Property("Label") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("label"); + b.Property("LastGrantedAt") .HasColumnType("timestamp with time zone") .HasColumnName("last_granted_at"); diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index 92777be..89970f4 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -24,6 +24,7 @@ using NodaTime.Serialization.SystemTextJson; using Quartz; using tusdotnet; using tusdotnet.Models; +using tusdotnet.Models.Configuration; using File = System.IO.File; var builder = WebApplication.CreateBuilder(args); @@ -34,7 +35,6 @@ builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = // Add services to the container. builder.Services.AddDbContext(); -builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); builder.Services.AddControllers().AddJsonOptions(options => @@ -189,7 +189,7 @@ var tusDiskStore = new tusdotnet.Stores.TusDiskStore( app.MapTus("/files/tus", (_) => Task.FromResult(new() { Store = tusDiskStore, - Events = new() + Events = new Events { OnAuthorizeAsync = async eventContext => { @@ -203,8 +203,8 @@ app.MapTus("/files/tus", (_) => Task.FromResult(new() } var httpContext = eventContext.HttpContext; - var user = httpContext.User; - if (!user.Identity?.IsAuthenticated ?? true) + var user = httpContext.Items["CurrentUser"] as Account; + if (user is null) { eventContext.FailRequest(HttpStatusCode.Unauthorized); return; @@ -223,12 +223,7 @@ app.MapTus("/files/tus", (_) => Task.FromResult(new() OnFileCompleteAsync = async eventContext => { var httpContext = eventContext.HttpContext; - var user = httpContext.User; - var userId = long.Parse(user.FindFirst("user_id")!.Value); - - var db = httpContext.RequestServices.GetRequiredService(); - var account = await db.Accounts.FindAsync(userId); - if (account is null) return; + if (httpContext.Items["CurrentUser"] is not Account user) return; var file = await eventContext.GetFileAsync(); var metadata = await file.GetMetadataAsync(eventContext.CancellationToken); @@ -238,7 +233,7 @@ app.MapTus("/files/tus", (_) => Task.FromResult(new() var fileService = eventContext.HttpContext.RequestServices.GetRequiredService(); - var info = await fileService.AnalyzeFileAsync(account, file.Id, fileStream, fileName, contentType); + var info = await fileService.AnalyzeFileAsync(user, file.Id, fileStream, fileName, contentType); var jsonOptions = httpContext.RequestServices.GetRequiredService>().Value .JsonSerializerOptions;