diff --git a/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs b/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs index 203ffc1..701fca4 100644 --- a/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs +++ b/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs @@ -14,6 +14,7 @@ using SecondDimensionWatcherReDive.AI.Models; using SecondDimensionWatcherReDive.Chat.External; using SecondDimensionWatcherReDive.Chat.Tools; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; namespace SecondDimensionWatcherReDive.Chat; @@ -66,16 +67,19 @@ public async Task GetModels(CancellationToken cancellationToken) } [HttpGet("conversations")] - public async Task> GetConversations( + public async Task GetConversations( CancellationToken cancellationToken) { - return await chatRepository.GetConversationsAsync(cancellationToken); + if (!User.TryGetProfileId(out var profileId)) return Unauthorized(); + return Ok(await chatRepository.GetConversationsAsync(profileId, cancellationToken)); } [HttpGet("conversations/{id:guid}")] public async Task GetConversation(Guid id, CancellationToken cancellationToken) { - var detail = await chatRepository.GetConversationWithMessagesAsync(id, cancellationToken); + if (!User.TryGetProfileId(out var profileId)) return Unauthorized(); + var detail = await chatRepository.GetConversationWithMessagesAsync( + id, profileId, cancellationToken); if (detail is null) { LogConversationNotFound(id); @@ -85,19 +89,25 @@ public async Task GetConversation(Guid id, CancellationToken canc } [HttpPost("conversations")] - public async Task CreateConversation( + [Authorize(Policy = AccessPolicies.ChatWrite)] + public async Task CreateConversation( [FromBody] CreateConversationRequest? request, CancellationToken cancellationToken) { - var conv = await chatRepository.CreateConversationAsync(request?.Title, cancellationToken); + if (!User.TryGetProfileId(out var profileId)) return Unauthorized(); + var conv = await chatRepository.CreateConversationAsync( + profileId, request?.Title, cancellationToken); LogConversationCreated(conv.Id, request?.Title); - return conv; + return Ok(conv); } [HttpDelete("conversations/{id:guid}")] + [Authorize(Policy = AccessPolicies.ChatWrite)] public async Task DeleteConversation(Guid id, CancellationToken cancellationToken) { - var deleted = await chatRepository.DeleteConversationAsync(id, cancellationToken); + if (!User.TryGetProfileId(out var profileId)) return Unauthorized(); + var deleted = await chatRepository.DeleteConversationAsync( + id, profileId, cancellationToken); if (deleted) LogConversationDeleted(id); else @@ -106,27 +116,34 @@ public async Task DeleteConversation(Guid id, CancellationToken c } [HttpPatch("conversations/{id:guid}")] + [Authorize(Policy = AccessPolicies.ChatWrite)] public async Task UpdateConversationTitle( Guid id, [FromBody] UpdateConversationRequest request, CancellationToken cancellationToken) { - await chatRepository.UpdateConversationTitleAsync(id, request.Title, cancellationToken); + if (!User.TryGetProfileId(out var profileId)) return Unauthorized(); + await chatRepository.UpdateConversationTitleAsync( + id, profileId, request.Title, cancellationToken); return Ok(); } [HttpPost("conversations/{id:guid}/messages")] + [Authorize(Policy = AccessPolicies.ChatWrite)] public async Task SendMessage( Guid id, [FromBody] SendMessageRequest request, CancellationToken cancellationToken) { + if (!User.TryGetProfileId(out var profileId)) + return TypedResults.Unauthorized(); var aiEngine = serviceProvider.GetService(); var status = serviceProvider.GetService(); if (aiEngine is null || status is { IsConfigured: false }) return TypedResults.StatusCode(503); - var conversation = await chatRepository.GetConversationWithMessagesAsync(id, cancellationToken); + var conversation = await chatRepository.GetConversationWithMessagesAsync( + id, profileId, cancellationToken); if (conversation is null) { LogConversationNotFound(id); @@ -134,13 +151,15 @@ public async Task SendMessage( } // Get current message count for ordering - var messageOrder = await chatRepository.GetMessageCountAsync(id, cancellationToken); + var messageOrder = await chatRepository.GetMessageCountAsync( + id, profileId, cancellationToken); // Save user message var userMessage = new ChatMessageRecord( Guid.NewGuid(), "user", request.Content, null, null, null, messageOrder, DateTimeOffset.Now); - await chatRepository.AddMessageAsync(id, userMessage, cancellationToken); + await chatRepository.AddMessageAsync( + id, profileId, userMessage, cancellationToken); messageOrder++; LogUserMessageReceived(id, messageOrder - 1, request.Content.Length); @@ -154,15 +173,21 @@ public async Task SendMessage( var messages = BuildMessagesFromHistory(conversation.Messages, request.Content); LogHistoryBuilt(id, messages.Count); - var toolExecutor = new ToolExecutorBuilder(serviceProvider) + IToolExecutorBuilder toolBuilder = new ToolExecutorBuilder(serviceProvider) .AddTool() - .AddTool() .AddTool() - .AddTool() - .AddTool() - .AddTool() - .AddTool() - .Build(); + .AddTool(); + if (User.IsInRole(nameof(UserRole.Admin)) + || User.IsInRole(nameof(UserRole.Member))) + { + toolBuilder = toolBuilder + .AddTool() + .AddTool() + .AddTool(); + } + if (User.IsInRole(nameof(UserRole.Admin))) + toolBuilder = toolBuilder.AddTool(); + var toolExecutor = toolBuilder.Build(); var chatOptions = new ChatOptions { @@ -174,7 +199,7 @@ public async Task SendMessage( LogStreamingStarted(id, request.Model); return TypedResults.ServerSentEvents( - StreamChatEvents(aiEngine, messages, chatOptions, id, messageOrder, + StreamChatEvents(aiEngine, messages, chatOptions, id, profileId, messageOrder, request.Content, !hadPriorAssistant && titleEligible, request.Model, cancellationToken)); } @@ -184,6 +209,7 @@ private async IAsyncEnumerable> StreamChatEvents( List messages, ChatOptions chatOptions, Guid conversationId, + Guid profileId, int messageOrder, string firstUserMessage, bool autoTitleEligible, @@ -196,7 +222,7 @@ private async IAsyncEnumerable> StreamChatEvents( // Keep the task and await it during iterator disposal so a disconnected request cannot // release this controller's scoped repository before tool-call audit records are saved. var producer = ProduceChatEventsAsync( - aiEngine, messages, chatOptions, conversationId, messageOrder, + aiEngine, messages, chatOptions, conversationId, profileId, messageOrder, firstUserMessage, autoTitleEligible, model, channel.Writer, cancellationToken); @@ -219,6 +245,7 @@ private async Task ProduceChatEventsAsync( List messages, ChatOptions chatOptions, Guid conversationId, + Guid profileId, int messageOrder, string firstUserMessage, bool autoTitleEligible, @@ -344,7 +371,8 @@ await writer.WriteAsync( if (messagesToSave.Count > 0) { - await chatRepository.AddMessagesAsync(conversationId, messagesToSave, CancellationToken.None); + await chatRepository.AddMessagesAsync( + conversationId, profileId, messagesToSave, CancellationToken.None); LogMessagesSaved(conversationId, messagesToSave.Count); // Capture data needed for the post-stream auto-title task. @@ -372,7 +400,8 @@ await writer.WriteAsync( // stalled provider can never hang the conversation. if (firstAssistantContentForTitle is not null) { - _ = RunAutoTitleAsync(conversationId, firstUserMessage, firstAssistantContentForTitle, model); + _ = RunAutoTitleAsync( + conversationId, profileId, firstUserMessage, firstAssistantContentForTitle, model); } } @@ -394,7 +423,11 @@ private static async Task WriteToolAuditEventAsync( } private async Task RunAutoTitleAsync( - Guid conversationId, string firstUserMessage, string firstAssistantMessage, string? model) + Guid conversationId, + Guid profileId, + string firstUserMessage, + string firstAssistantMessage, + string? model) { try { @@ -402,7 +435,12 @@ private async Task RunAutoTitleAsync( var generator = scope.ServiceProvider.GetRequiredService(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await generator.TryAutoTitleAsync( - conversationId, firstUserMessage, firstAssistantMessage, model, cts.Token); + conversationId, + profileId, + firstUserMessage, + firstAssistantMessage, + model, + cts.Token); } catch (Exception ex) { diff --git a/Plugins/SecondDimensionWatcherReDive.Chat/ChatServiceExtensions.cs b/Plugins/SecondDimensionWatcherReDive.Chat/ChatServiceExtensions.cs index be8e6e1..ac2e9dc 100644 --- a/Plugins/SecondDimensionWatcherReDive.Chat/ChatServiceExtensions.cs +++ b/Plugins/SecondDimensionWatcherReDive.Chat/ChatServiceExtensions.cs @@ -7,6 +7,7 @@ public static class ChatServiceExtensions { public static IServiceCollection AddChat(this IServiceCollection services) { + services.AddHttpContextAccessor(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Plugins/SecondDimensionWatcherReDive.Chat/ConversationTitleGenerator.cs b/Plugins/SecondDimensionWatcherReDive.Chat/ConversationTitleGenerator.cs index 140a2e1..a10bdbd 100644 --- a/Plugins/SecondDimensionWatcherReDive.Chat/ConversationTitleGenerator.cs +++ b/Plugins/SecondDimensionWatcherReDive.Chat/ConversationTitleGenerator.cs @@ -17,6 +17,7 @@ internal interface IConversationTitleGenerator Task TryAutoTitleAsync( Guid conversationId, + Guid profileId, string userMessage, string assistantMessage, string? model, @@ -93,6 +94,7 @@ internal sealed partial class ConversationTitleGenerator( public async Task TryAutoTitleAsync( Guid conversationId, + Guid profileId, string userMessage, string assistantMessage, string? model, @@ -108,7 +110,8 @@ public async Task TryAutoTitleAsync( } // Race-safety: only persist if title is still unset on the latest snapshot. - var current = await chatRepository.GetConversationWithMessagesAsync(conversationId, cancellationToken); + var current = await chatRepository.GetConversationWithMessagesAsync( + conversationId, profileId, cancellationToken); if (current is null) return; if (!IsAutoTitleEligible(current.Title)) @@ -117,7 +120,8 @@ public async Task TryAutoTitleAsync( return; } - await chatRepository.UpdateConversationTitleAsync(conversationId, title, cancellationToken); + await chatRepository.UpdateConversationTitleAsync( + conversationId, profileId, title, cancellationToken); LogTitleSaved(conversationId, title); } catch (OperationCanceledException) diff --git a/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs b/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs index 831ec4b..c6085a4 100644 --- a/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs +++ b/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs @@ -1,6 +1,9 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using SecondDimensionWatcherReDive.AI.Models; using SecondDimensionWatcherReDive.Framework.AI; using SecondDimensionWatcherReDive.Framework.Attributes; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileDownload; @@ -12,7 +15,9 @@ namespace SecondDimensionWatcherReDive.Chat.Tools; internal sealed partial class ManageDownloadsTool( IAnimationInfoRepository animationInfoRepository, IFileMappingRepository fileMappingRepository, - IFileDownloadClientProvider fileDownloadClientProvider) : ITool + IFileDownloadClientProvider fileDownloadClientProvider, + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService) : ITool { private async Task ExecuteCoreAsync( ManageDownloadsParams param, CancellationToken cancellationToken) @@ -111,6 +116,14 @@ private async Task ResumeDownloadAsync( private async Task CancelDownloadAsync( AnimationInfo info, IFileDownloadClient client, bool removeFile, CancellationToken cancellationToken) { + if (removeFile) + { + var principal = httpContextAccessor.HttpContext?.User; + if (principal is null || !(await authorizationService.AuthorizeAsync( + principal, resource: null, AccessPolicies.RecentAdministrator)).Succeeded) + return new ToolFailureResult("Deleting downloaded files requires recent administrator authentication"); + } + var cancellationAttemptId = info.DownloadCancellationId ?? Guid.NewGuid(); cancellationToken.ThrowIfCancellationRequested(); using (var beginCancellation = CreateDownloadSagaTokenSource()) diff --git a/README.md b/README.md index a55a5f4..e4bad8c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - [x] HTTP 文件浏览和流媒体播放,支持外部播放器(VLC / PotPlayer / IINA / mpv / nPlayer)URL Scheme - [x] WebDAV 只读网关(RFC 4918,按设备签发的 Basic 访问令牌,独立于 JWT) - [x] JWT 认证 + 刷新令牌 +- [x] 家庭账户与独立档案(Admin / Member / Viewer、PIN、会话撤销、独立播放/聊天状态) - [x] AI 元数据推断(OpenAI / Anthropic)— 自动识别 TMDB ID、季度、集数、字幕组 - [x] 本地 Agent 执行模式(Codex app-server)— 同时用于元数据推断与对话助手 - [x] AI 对话助手:流式响应 + 7 个内置工具(动画 / 订阅 / 季度 / 下载 / 任务 / 文件查询) @@ -90,7 +91,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/HCGStudio/SecondDimensionWat |--------|------| | `ConnectionStrings:sdw` | PostgreSQL 连接字符串 | | `JwtSecret` | JWT 签名密钥 | -| `Password:Value` | 登录密码的 BCrypt 哈希;为空时允许首次注册写入 `password.json` | +| `Password:Value` | 旧版单站点密码的 BCrypt 哈希;存在时禁止公开注册,仅允许 `admin` 首次登录并迁移到数据库账户 | | `DataProtection:KeyRingPath` | 网页保存的 API key/密码所用加密密钥环;必须位于持久化目录 | | `Torrent:Remote:Url` | qBittorrent API 地址 | | `FileStore:Local` | 下载文件存储根目录 | @@ -118,6 +119,16 @@ bash <(curl -fsSL https://raw.githubusercontent.com/HCGStudio/SecondDimensionWat 设置页提交的 API key 和密码会经过浏览器与服务端之间的连接;除严格的本机访问外,必须为网页入口配置 HTTPS。配置带凭据的 AI 或 qBittorrent 端点时也应使用 TLS,或将明文 HTTP 严格限制在受信任的隔离网络内。 +### 家庭账户、档案与设备访问 + +首次安装由注册页创建管理员和默认档案;旧实例若仍配置 `Password:Value`,注册入口会保持关闭,使用用户名 `admin` 和原密码首次登录后才会安全迁移。右上角档案菜单可即时切换档案,「账户与档案」页可管理名称、头像、可选 PIN、家庭用户和登录会话。档案切换会轮换访问/刷新令牌,并清除浏览器中上一档案的播放、聊天等缓存;多个标签页通过 Web Locks 与浏览器消息同步轮换结果。 + +角色权限由服务端强制执行:Admin 可管理全局设置、用户、任务、元数据和设备凭据;Member 可管理订阅、下载任务和播放状态,但删除已下载文件仍需近期管理员验证;Viewer 仅可浏览和播放。敏感管理操作在超过近期验证窗口后会要求再次输入账户密码,无需退出登录。 + +管理员可在「设置 → 访问协议」为指定家庭用户签发 WebDAV/VFS 设备凭据。每个凭据固定为只读,可限制虚拟根路径并设置到期时间;撤销、到期和路径边界同时由 WebDAV 与 VFS 强制执行。路径 `/Anime` 不会授权 `/Anime2`,客户端看到的根目录和 WebDAV href 会重写到所授权的命名空间。 + +升级迁移会把旧播放进度、偏好和聊天记录归入默认 `Home` 档案。旧数据库结构无法表达多用户、档案归属、token 根路径、到期或撤销状态;因此一旦创建了新身份数据或受限设备凭据,向该迁移之前降级会在删除任何列之前明确失败并保持数据库原样,避免静默合并历史或扩大已撤销凭据权限。 + ### 使用本地 Codex app-server 需要 Codex app-server 0.144.5 或兼容版本提供实验性的 `permissionProfile/list` 与 `permissions` 协议。应用默认请求 `:read-only` 权限配置,并在每次创建 thread 后核验服务端实际返回 `readOnly` 且 agent network access 为 `false`;服务端不支持该协议、配置不可用或结果更宽松时会拒绝执行。当前 `:read-only` **不会把主机文件读取范围收窄到空目录**,而 agent sandbox 的网络开关也不限制 app-server 自身访问模型 API,所以仍必须把进程当作能够读取其操作系统账号可读文件的服务来隔离。 diff --git a/SecondDimensionWatcherReDive.Client/package.json b/SecondDimensionWatcherReDive.Client/package.json index 5f71a25..a9203a0 100644 --- a/SecondDimensionWatcherReDive.Client/package.json +++ b/SecondDimensionWatcherReDive.Client/package.json @@ -33,6 +33,7 @@ "@parcel/core": "^2.16.4", "@parcel/transformer-inline-string": "2.16.4", "@trivago/prettier-plugin-sort-imports": "^6.0.2", + "@types/node": "^26.4.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", "@yarnpkg/sdks": "^3.3.1", @@ -52,7 +53,7 @@ "build": "rimraf dist && parcel build --no-source-maps", "mock": "node mock-server.mjs", "dev": "node mock-server.mjs & parcel --no-cache", - "test": "tsx --test src/playback/mkv/*.test.ts" + "test": "tsx --test src/auth/*.test.ts src/playback/mkv/*.test.ts" }, "source": "src/index.html", "@parcel/resolver-default": { diff --git a/SecondDimensionWatcherReDive.Client/src/Main.tsx b/SecondDimensionWatcherReDive.Client/src/Main.tsx index 47a5f24..5fe4209 100644 --- a/SecondDimensionWatcherReDive.Client/src/Main.tsx +++ b/SecondDimensionWatcherReDive.Client/src/Main.tsx @@ -3,7 +3,9 @@ import { useTranslation } from "react-i18next"; import { createBrowserRouter } from "react-router"; import { RouterProvider } from "react-router/dom"; +import { useAuthSynchronization } from "./auth/hooks"; import { ProtectedRoute } from "./components/ProtectedRoute"; +import { AccountPage } from "./pages/AccountPage"; import { ChatPage } from "./pages/ChatPage"; import { DownloadedPage } from "./pages/DownloadedPage"; import { DownloadingPage } from "./pages/DownloadingPage"; @@ -136,6 +138,15 @@ const router = createBrowserRouter([ ), errorElement: , }, + { + path: "/account", + element: ( + + + + ), + errorElement: , + }, { path: "/settings", element: ( @@ -153,6 +164,7 @@ const router = createBrowserRouter([ ]); export const Main: React.FC = () => { + useAuthSynchronization(); const { t } = useTranslation(); React.useEffect(() => { document.title = `${t("appName")} Re:Dive`; diff --git a/SecondDimensionWatcherReDive.Client/src/accounts/api.ts b/SecondDimensionWatcherReDive.Client/src/accounts/api.ts new file mode 100644 index 0000000..d949a4a --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/accounts/api.ts @@ -0,0 +1,58 @@ +import { IAuthProfile, UserRole } from "../auth/IAuthResult"; +import fetcher from "../auth/httpClient"; +import { IUserAccount } from "./types"; + +export const createProfile = (value: { + name: string; + avatar?: string; + pin?: string; +}) => + fetcher("/api/accounts/profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(value), + }); + +export const updateProfile = ( + id: string, + value: { + name: string; + avatar?: string; + pin?: string; + currentPin?: string; + replacePin: boolean; + }, +) => + fetcher(`/api/accounts/profiles/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(value), + }); + +export const revokeSession = (id: string, asAdministrator = false) => + fetcher(`/api/accounts/sessions/${id}${asAdministrator ? "/admin" : ""}`, { + method: "DELETE", + }); + +export const createUser = (value: { + username: string; + password: string; + role: UserRole; + profileName: string; +}) => + fetcher("/api/accounts/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(value), + }); + +export const updateUserAccess = ( + id: string, + role: UserRole, + isDisabled: boolean, +) => + fetcher(`/api/accounts/users/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role, isDisabled }), + }); diff --git a/SecondDimensionWatcherReDive.Client/src/accounts/hooks.ts b/SecondDimensionWatcherReDive.Client/src/accounts/hooks.ts new file mode 100644 index 0000000..a117af3 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/accounts/hooks.ts @@ -0,0 +1,23 @@ +import useSWR from "swr"; + +import { IAuthProfile } from "../auth/IAuthResult"; +import fetcher from "../auth/httpClient"; +import { IAccountSession, IUserAccount } from "./types"; + +export const useProfiles = () => + useSWR("/api/accounts/profiles", fetcher); + +export const useSessions = () => + useSWR("/api/accounts/sessions", fetcher); + +export const useUsers = (isAdministrator: boolean) => + useSWR( + isAdministrator ? "/api/accounts/users" : null, + fetcher, + ); + +export const useAllSessions = (isAdministrator: boolean) => + useSWR( + isAdministrator ? "/api/accounts/sessions/all" : null, + fetcher, + ); diff --git a/SecondDimensionWatcherReDive.Client/src/accounts/types.ts b/SecondDimensionWatcherReDive.Client/src/accounts/types.ts new file mode 100644 index 0000000..2ecdea2 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/accounts/types.ts @@ -0,0 +1,25 @@ +import { IAuthProfile, UserRole } from "../auth/IAuthResult"; + +export interface IAccountSession { + id: string; + userId: string; + username: string; + profileId: string; + profileName: string; + deviceName?: string; + authenticatedAt: string; + createdAt: string; + lastSeenAt: string; + expiresAt: string; + revokedAt?: string; + isCurrent: boolean; +} + +export interface IUserAccount { + id: string; + username: string; + role: UserRole; + isDisabled: boolean; + createdAt: string; + profiles: IAuthProfile[]; +} diff --git a/SecondDimensionWatcherReDive.Client/src/auth/IAuthResult.ts b/SecondDimensionWatcherReDive.Client/src/auth/IAuthResult.ts index 6374424..5f5a15b 100644 --- a/SecondDimensionWatcherReDive.Client/src/auth/IAuthResult.ts +++ b/SecondDimensionWatcherReDive.Client/src/auth/IAuthResult.ts @@ -2,4 +2,25 @@ export interface IAuthResult { token: string; refreshToken: string; success: boolean; + sessionId?: string; + profileId?: string; +} + +export type UserRole = "Admin" | "Member" | "Viewer"; + +export interface IAuthProfile { + id: string; + name: string; + avatar?: string; + hasPin: boolean; + isDefault: boolean; +} + +export interface IAuthState { + userId: string; + username: string; + role: UserRole; + sessionId: string; + profileId: string; + profiles: IAuthProfile[]; } diff --git a/SecondDimensionWatcherReDive.Client/src/auth/hooks.ts b/SecondDimensionWatcherReDive.Client/src/auth/hooks.ts index 962f326..57e8102 100644 --- a/SecondDimensionWatcherReDive.Client/src/auth/hooks.ts +++ b/SecondDimensionWatcherReDive.Client/src/auth/hooks.ts @@ -1,5 +1,63 @@ -import useSwr from "swr"; +import React from "react"; +import useSwr, { mutate } from "swr"; + +import { IAuthState } from "./IAuthResult"; +import { AuthChangeDetail, subscribeToAuthChanges } from "./httpClient"; export const useAllowRegister = () => useSwr<{ allow: boolean }>("/api/auth/allowRegister"); -export const useLoginStatus = () => useSwr("/api/auth/verify"); +export const useLoginStatus = () => useSwr("/api/auth/verify"); +export const useAccess = () => { + const { data } = useLoginStatus(); + return { + isAdministrator: data?.role === "Admin", + canContentWrite: data?.role === "Admin" || data?.role === "Member", + canPlaybackWrite: data?.role === "Admin" || data?.role === "Member", + }; +}; + +type CacheMutator = ( + key: string | ((key: unknown) => boolean), + data?: unknown, + options?: { revalidate?: boolean }, +) => Promise; + +export const applyAuthChange = async ( + { auth, profileChanged }: AuthChangeDetail, + mutateCache: CacheMutator = mutate as CacheMutator, + redirectToLogin: () => void = () => window.location.assign("/login"), + reloadForProfileChange: () => void = () => window.location.reload(), +) => { + const apiKeys = (key: unknown) => { + const candidate = Array.isArray(key) ? key[0] : key; + return typeof candidate === "string" && candidate.startsWith("/api/"); + }; + if (!auth || profileChanged) { + // Remove every profile-scoped response before any component can render + // under the new identity. + await mutateCache(apiKeys, undefined, { revalidate: false }); + } + + if (!auth) { + if (window.location.pathname !== "/login") redirectToLogin(); + return; + } + + if (profileChanged) { + // A reload is a security boundary: it unmounts the player/chat and all + // profile-owned local state before the replacement identity can render. + reloadForProfileChange(); + } else { + await mutateCache("/api/auth/verify"); + } +}; + +export const useAuthSynchronization = () => { + React.useEffect( + () => + subscribeToAuthChanges((detail) => { + void applyAuthChange(detail); + }), + [], + ); +}; diff --git a/SecondDimensionWatcherReDive.Client/src/auth/httpClient.test.ts b/SecondDimensionWatcherReDive.Client/src/auth/httpClient.test.ts new file mode 100644 index 0000000..fd412fb --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/auth/httpClient.test.ts @@ -0,0 +1,291 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { IAuthResult } from "./IAuthResult"; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + get length() { + return this.values.size; + } + clear() { + this.values.clear(); + } + getItem(key: string) { + return this.values.get(key) ?? null; + } + key(index: number) { + return [...this.values.keys()][index] ?? null; + } + removeItem(key: string) { + this.values.delete(key); + } + setItem(key: string, value: string) { + this.values.set(key, value); + } +} + +class ExclusiveLocks { + private tail: Promise = Promise.resolve(); + + request( + _name: string, + _options: { mode: "exclusive" }, + callback: () => Promise, + ): Promise { + const result = this.tail.then(callback); + this.tail = result.catch(() => undefined); + return result; + } +} + +const stale: IAuthResult = { + token: "access-a", + refreshToken: "refresh-a", + sessionId: "session", + profileId: "profile-a", + success: true, +}; +const fresh: IAuthResult = { + token: "access-b", + refreshToken: "refresh-b", + sessionId: "session", + profileId: "profile-a", + success: true, +}; + +test("cross-tab refresh lock serializes rotation and reuses the winner", async () => { + const memoryStorage = new MemoryStorage(); + const windowTarget = new EventTarget() as EventTarget & { + location: { pathname: string; href: string; assign(path: string): void }; + }; + windowTarget.location = { + pathname: "/", + href: "/", + assign(path: string) { + this.href = path; + }, + }; + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: memoryStorage, + }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: windowTarget, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { locks: new ExclusiveLocks() }, + }); + Object.defineProperty(globalThis, "BroadcastChannel", { + configurable: true, + value: undefined, + }); + if (typeof CustomEvent === "undefined") { + class TestCustomEvent extends Event { + constructor( + type: string, + readonly init: CustomEventInit, + ) { + super(type); + } + get detail() { + return this.init.detail as T; + } + } + Object.defineProperty(globalThis, "CustomEvent", { + configurable: true, + value: TestCustomEvent, + }); + } + + let refreshCalls = 0; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async () => { + refreshCalls++; + await Promise.resolve(); + return new Response(JSON.stringify(fresh), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }); + + const auth = await import("./httpClient"); + auth.setAuthResult(stale); + + const [first, second] = await Promise.all([ + auth.refreshAuthSession(stale), + auth.refreshAuthSession(stale), + ]); + + assert.equal(refreshCalls, 1); + assert.deepEqual(first, fresh); + assert.deepEqual(second, fresh); + assert.deepEqual(JSON.parse(memoryStorage.getItem("auth")!), fresh); +}); + +test("profile changes clear array-keyed caches then reload and logout redirects", async () => { + const { applyAuthChange } = await import("./hooks"); + const calls: Array<{ + key: string | ((key: unknown) => boolean); + options?: { revalidate?: boolean }; + }> = []; + let reloaded = false; + const mutate = async ( + key: string | ((key: unknown) => boolean), + _data?: unknown, + options?: { revalidate?: boolean }, + ) => { + calls.push({ key, options }); + }; + + await applyAuthChange( + { auth: { ...fresh, profileId: "profile-b" }, profileChanged: true }, + mutate, + undefined, + () => { + reloaded = true; + }, + ); + + assert.equal(calls.length, 1); + assert.equal(typeof calls[0].key, "function"); + assert.equal(calls[0].options?.revalidate, false); + const apiKeyPredicate = calls[0].key as (key: unknown) => boolean; + assert.equal(apiKeyPredicate(["/api/chat/conversations", "profile-a"]), true); + assert.equal(apiKeyPredicate(["settings", "profile-a"]), false); + assert.equal(reloaded, true); + + let redirected = false; + calls.length = 0; + await applyAuthChange({ auth: null, profileChanged: true }, mutate, () => { + redirected = true; + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].options?.revalidate, false); + assert.equal(redirected, true); +}); + +test("a late refresh response cannot overwrite a newer shared identity", async () => { + const auth = await import("./httpClient"); + auth.setAuthResult(fresh); + let finishRefresh: ((response: Response) => void) | undefined; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: () => + new Promise((resolve) => { + finishRefresh = resolve; + }), + }); + + const refresh = auth.refreshAuthSession(fresh); + await Promise.resolve(); + const replacement: IAuthResult = { + ...fresh, + token: "access-new-session", + refreshToken: "refresh-new-session", + sessionId: "session-new", + profileId: "profile-new", + }; + // This is the shared-storage write made by another realm; intentionally do + // not dispatch storage yet, reproducing the narrow response/notification race. + localStorage.setItem("auth", JSON.stringify(replacement)); + finishRefresh?.( + new Response( + JSON.stringify({ + ...fresh, + token: "late-access-a", + refreshToken: "late-refresh-a", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await assert.rejects(refresh, auth.AuthIdentityChangedError); + assert.deepEqual(JSON.parse(localStorage.getItem("auth")!), replacement); + + // Restore the original realm for the remaining state-machine tests. + localStorage.setItem("auth", JSON.stringify(fresh)); +}); + +test("Viewer playback is read-only while writable roles retain profile mutations", async () => { + const auth = await import("./httpClient"); + auth.setAuthResult(fresh); + const identity = auth.getAuthIdentityKey(); + + assert.equal(auth.canSendProfileMutation(identity, false), false); + assert.equal(auth.canSendProfileMutation(identity, true), true); +}); + +test("late logout cleanup preserves a replacement login session", async () => { + const auth = await import("./httpClient"); + auth.setAuthResult(fresh); + + assert.equal(auth.clearAuthForSession("an-older-session"), false); + assert.deepEqual(JSON.parse(localStorage.getItem("auth")!), fresh); +}); + +test("external-tab storage profile change aborts streams and forbids 401 replay", async () => { + const auth = await import("./httpClient"); + auth.setAuthResult(fresh); + const oldIdentity = auth.getAuthIdentityKey(); + const stream = auth.beginAuthBoundRequest(true); + let observedChange: import("./httpClient").AuthChangeDetail | undefined; + const unsubscribe = auth.subscribeToAuthChanges((detail) => { + observedChange = detail; + }); + + let finishFirstRequest: ((response: Response) => void) | undefined; + let fetchCalls = 0; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: () => { + fetchCalls += 1; + return new Promise((resolve) => { + finishFirstRequest = resolve; + }); + }, + }); + + const oldMutation = auth.default("/api/playback/progress", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + await Promise.resolve(); + assert.equal(fetchCalls, 1); + + const remoteProfile: IAuthResult = { + ...fresh, + token: "access-profile-b", + refreshToken: "refresh-profile-b", + profileId: "profile-b", + }; + localStorage.setItem("auth", JSON.stringify(remoteProfile)); + const storageEvent = new Event("storage"); + Object.defineProperty(storageEvent, "key", { value: "auth" }); + window.dispatchEvent(storageEvent); + + assert.equal(observedChange?.profileChanged, true); + assert.equal(observedChange?.auth?.profileId, "profile-b"); + assert.equal( + stream.signal.aborted, + true, + "the old chat/SSE signal is aborted", + ); + assert.equal(auth.canSendProfileMutation(oldIdentity, true), false); + assert.throws( + () => auth.beginAuthBoundRequest(true), + auth.AuthIdentityChangedError, + ); + + finishFirstRequest?.(new Response(null, { status: 401 })); + await assert.rejects(oldMutation, auth.AuthIdentityChangedError); + assert.equal(fetchCalls, 1, "401 was not refreshed or replayed as profile B"); + + unsubscribe(); + stream.dispose(); +}); diff --git a/SecondDimensionWatcherReDive.Client/src/auth/httpClient.ts b/SecondDimensionWatcherReDive.Client/src/auth/httpClient.ts index 1681a53..7fef28a 100644 --- a/SecondDimensionWatcherReDive.Client/src/auth/httpClient.ts +++ b/SecondDimensionWatcherReDive.Client/src/auth/httpClient.ts @@ -1,89 +1,492 @@ import { IAuthResult } from "./IAuthResult"; -import { refreshJwtToken } from "./utils"; +import { refreshJwtToken } from "./sessionApi"; -let authResult: IAuthResult | null = null; +const AUTH_STORAGE_KEY = "auth"; +const AUTH_CHANNEL_NAME = "sdw-auth"; +const AUTH_REFRESH_LOCK = "sdw-auth-refresh"; +const AUTH_CHANGED_EVENT = "sdw-auth-changed"; + +const mutationMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +type AuthSyncMessage = + { type: "updated"; value: IAuthResult } | { type: "cleared" }; + +export interface AuthChangeDetail { + auth: IAuthResult | null; + profileChanged: boolean; +} + +export const getAuthIdentityKey = ( + value: IAuthResult | null = getAuthResult(), +): string | null => + value?.sessionId && value.profileId + ? `${value.sessionId}\u0000${value.profileId}` + : null; + +const hasSameIdentity = ( + left: IAuthResult | null, + right: IAuthResult | null, +): boolean => + Boolean( + left && right && getAuthIdentityKey(left) === getAuthIdentityKey(right), + ); + +let identityTransitionInProgress = false; +const identityRequests = new Map>(); + +const abortIdentityRequests = (identityKey: string | null) => { + if (!identityKey) return; + const controllers = identityRequests.get(identityKey); + if (!controllers) return; + for (const controller of controllers) controller.abort(); + identityRequests.delete(identityKey); +}; + +const notifyAuthChanged = ( + previous: IAuthResult | null, + current: IAuthResult | null, +) => { + if (typeof window === "undefined") return; + const profileChanged = Boolean( + current && + (identityTransitionInProgress || + (previous && !hasSameIdentity(previous, current))), + ); + if (!current || profileChanged) { + // This runs synchronously before React/SWR sees the new identity. It closes + // streams and requests which captured the old profile, and prevents + // beforeunload/cleanup mutations from being sent with the replacement JWT. + identityTransitionInProgress = true; + abortIdentityRequests(getAuthIdentityKey(previous)); + } else if (!previous) { + // A fresh login can resume mutations. Once a profile transition has + // started, duplicate storage/BroadcastChannel delivery must not reopen the + // old page before the synchronization hook reloads it. + identityTransitionInProgress = false; + } + window.dispatchEvent( + new CustomEvent(AUTH_CHANGED_EVENT, { + detail: { + auth: current, + profileChanged, + }, + }), + ); +}; + +export const subscribeToAuthChanges = ( + listener: (detail: AuthChangeDetail) => void, +): (() => void) => { + if (typeof window === "undefined") return () => undefined; + const handler = (event: Event) => + listener((event as CustomEvent).detail); + window.addEventListener(AUTH_CHANGED_EVENT, handler); + return () => window.removeEventListener(AUTH_CHANGED_EVENT, handler); +}; + +const storage = (): Storage | null => + typeof localStorage === "undefined" ? null : localStorage; + +const readStoredAuth = (): IAuthResult | null => { + const raw = storage()?.getItem(AUTH_STORAGE_KEY); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as IAuthResult; + return parsed?.success && + parsed.token && + parsed.refreshToken && + parsed.sessionId && + parsed.profileId + ? parsed + : null; + } catch { + storage()?.removeItem(AUTH_STORAGE_KEY); + return null; + } +}; + +let authResult: IAuthResult | null = readStoredAuth(); let refreshPromise: Promise | null = null; +let authChannel: BroadcastChannel | null = null; + +const receiveAuthMessage = (message: AuthSyncMessage) => { + const previous = authResult; + authResult = message.type === "updated" ? message.value : null; + notifyAuthChanged(previous, authResult); +}; -function clearAuth() { +if (typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + if (event.key !== AUTH_STORAGE_KEY) return; + const previous = authResult; + authResult = readStoredAuth(); + notifyAuthChanged(previous, authResult); + }); + + if (typeof BroadcastChannel !== "undefined") { + authChannel = new BroadcastChannel(AUTH_CHANNEL_NAME); + authChannel.addEventListener( + "message", + (event: MessageEvent) => { + receiveAuthMessage(event.data); + }, + ); + } +} + +function clearAuth(expectedRefreshToken?: string): boolean { + const current = readStoredAuth(); + if ( + expectedRefreshToken && + current && + current.refreshToken !== expectedRefreshToken + ) { + authResult = current; + return false; + } + const previous = authResult ?? current; authResult = null; - localStorage.removeItem("auth"); + storage()?.removeItem(AUTH_STORAGE_KEY); + authChannel?.postMessage({ type: "cleared" } satisfies AuthSyncMessage); + notifyAuthChanged(previous, null); + return true; } export const setAuthResult = (result: IAuthResult) => { if (result && result.success) { + const previous = authResult ?? readStoredAuth(); authResult = result; - localStorage.setItem("auth", JSON.stringify(result)); + storage()?.setItem(AUTH_STORAGE_KEY, JSON.stringify(result)); + authChannel?.postMessage({ + type: "updated", + value: result, + } satisfies AuthSyncMessage); + notifyAuthChanged(previous, result); } }; +export const getAuthResult = (): IAuthResult | null => + readStoredAuth() ?? authResult; + export { clearAuth }; -async function parseJsonSafe(res: Response): Promise { - const text = await res.text(); - return text ? JSON.parse(text) : undefined; +export const clearAuthForSession = (sessionId?: string): boolean => { + const current = getAuthResult(); + return current && sessionId && current.sessionId !== sessionId + ? false + : clearAuth(); +}; + +export class AuthIdentityChangedError extends Error { + constructor() { + super("Authentication identity changed"); + this.name = "AuthIdentityChangedError"; + } } -export default async function fetcher( - input: RequestInfo, - init?: RequestInit, -): Promise { - if (authResult) { - const res = await fetch(input, { - ...init, - headers: { - ...init?.headers, - Authorization: `Bearer ${authResult.token}`, - }, +export interface AuthBoundRequest { + auth: IAuthResult; + identityKey: string; + signal: AbortSignal; + isCurrent(): boolean; + abort(): void; + dispose(): void; +} + +/** + * Capture the session/profile for a request. Profile changes synchronously + * abort every bound request, including streaming responses. Mutations are not + * allowed once an identity transition has started because component teardown + * must never flush old profile state under the new JWT. + */ +export const beginAuthBoundRequest = ( + mutation = false, + externalSignal?: AbortSignal | null, +): AuthBoundRequest => { + if (mutation && identityTransitionInProgress) { + throw new AuthIdentityChangedError(); + } + const auth = getAuthResult(); + const identityKey = getAuthIdentityKey(auth); + if (!auth || !identityKey) throw new Error("Unauthorized"); + + const controller = new AbortController(); + const controllers = identityRequests.get(identityKey) ?? new Set(); + controllers.add(controller); + identityRequests.set(identityKey, controllers); + + const abortFromExternalSignal = () => controller.abort(); + if (externalSignal?.aborted) controller.abort(); + else + externalSignal?.addEventListener("abort", abortFromExternalSignal, { + once: true, }); - if (res.status !== 401) { - if (!res.ok) { - throw new Error(`${res.status}`); - } - return await parseJsonSafe(res); - } + let disposed = false; + const dispose = () => { + if (disposed) return; + disposed = true; + externalSignal?.removeEventListener("abort", abortFromExternalSignal); + controllers.delete(controller); + if (controllers.size === 0) identityRequests.delete(identityKey); + }; - // Token expired — deduplicate concurrent refresh calls - if (!refreshPromise) { - refreshPromise = refreshJwtToken(authResult).finally(() => { - refreshPromise = null; - }); + return { + auth, + identityKey, + signal: controller.signal, + isCurrent: () => + !controller.signal.aborted && + !identityTransitionInProgress && + getAuthIdentityKey() === identityKey, + abort: () => controller.abort(), + dispose, + }; +}; + +export const canSendProfileMutation = ( + capturedIdentityKey: string | null, + hasWriteAccess = true, +): boolean => + Boolean( + hasWriteAccess && + capturedIdentityKey && + !identityTransitionInProgress && + getAuthIdentityKey() === capturedIdentityKey, + ); + +type LockManagerWithRequest = { + request( + name: string, + options: { mode: "exclusive" }, + callback: () => Promise, + ): Promise; +}; + +const withRefreshLock = async (callback: () => Promise): Promise => { + const locks = ( + typeof navigator !== "undefined" + ? (navigator as Navigator & { locks?: LockManagerWithRequest }).locks + : undefined + ) as LockManagerWithRequest | undefined; + return locks + ? locks.request(AUTH_REFRESH_LOCK, { mode: "exclusive" }, callback) + : callback(); +}; + +/** + * Rotating refresh tokens are shared through localStorage. The Web Lock makes + * the read/rotate/write sequence atomic across tabs; the second tab observes + * and reuses the token produced by the first instead of replaying its old one. + */ +export const refreshAuthSession = async ( + staleAuth: IAuthResult, +): Promise => + withRefreshLock(async () => { + const current = readStoredAuth(); + if (current && current.refreshToken !== staleAuth.refreshToken) { + if (!hasSameIdentity(current, staleAuth)) { + throw new AuthIdentityChangedError(); + } + authResult = current; + return current; } try { - const newAuth = await refreshPromise; - setAuthResult(newAuth); - } catch { - clearAuth(); - window.location.href = "/login"; - throw new Error("Unauthorized"); + const refreshInput = current ?? staleAuth; + const refreshed = await refreshJwtToken(refreshInput); + if (!refreshed.success || !refreshed.token || !refreshed.refreshToken) { + throw new Error("Unauthorized"); + } + if (!hasSameIdentity(refreshed, refreshInput)) { + throw new AuthIdentityChangedError(); + } + const beforeCommit = readStoredAuth(); + if (!beforeCommit || !hasSameIdentity(beforeCommit, refreshInput)) { + throw new AuthIdentityChangedError(); + } + if (beforeCommit.refreshToken !== refreshInput.refreshToken) { + // A lockless/concurrent same-identity refresh already won. Preserve its + // newer rotation rather than rolling shared storage backwards. + authResult = beforeCommit; + return beforeCommit; + } + setAuthResult(refreshed); + return refreshed; + } catch (error) { + // A browser without Web Locks can still receive a concurrent tab's + // BroadcastChannel/storage update before its failed request completes. + const latest = readStoredAuth(); + if (latest && latest.refreshToken !== staleAuth.refreshToken) { + if (!hasSameIdentity(latest, staleAuth)) { + throw new AuthIdentityChangedError(); + } + authResult = latest; + return latest; + } + clearAuth(staleAuth.refreshToken); + throw error; } + }); - // Retry with new token - const retryRes = await fetch(input, { - ...init, - headers: { - ...init?.headers, - Authorization: `Bearer ${authResult.token}`, - }, - }); +const isSuccessfulAuth = (value: IAuthResult): boolean => + value.success && Boolean(value.token) && Boolean(value.refreshToken); + +/** Serialize endpoints which themselves rotate the current refresh token. */ +export const rotateAuthenticatedSession = async ( + path: string, + body: (auth: IAuthResult) => unknown, +): Promise => + withRefreshLock(async () => { + let current = getAuthResult(); + if (!current) throw new Error("Unauthorized"); + const operationIdentity = getAuthIdentityKey(current); + + const send = (auth: IAuthResult) => + fetch(path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + body: JSON.stringify(body(auth)), + }); - if (retryRes.status === 401) { - clearAuth(); - window.location.href = "/login"; - throw new Error("Unauthorized"); + let response = await send(current); + const afterResponse = getAuthResult(); + if ( + getAuthIdentityKey(afterResponse) !== operationIdentity || + afterResponse?.refreshToken !== current.refreshToken + ) { + throw new AuthIdentityChangedError(); + } + if (response.status === 401) { + const latest = readStoredAuth(); + if (latest && latest.refreshToken !== current.refreshToken) { + if (!hasSameIdentity(latest, current)) { + throw new AuthIdentityChangedError(); + } + current = latest; + } else { + const refreshInput = current; + const refreshed = await refreshJwtToken(refreshInput); + if (!isSuccessfulAuth(refreshed)) throw new Error("Unauthorized"); + if (getAuthIdentityKey(refreshed) !== operationIdentity) { + throw new AuthIdentityChangedError(); + } + const shared = readStoredAuth(); + if (!shared || !hasSameIdentity(shared, refreshInput)) { + throw new AuthIdentityChangedError(); + } + if (shared.refreshToken !== refreshInput.refreshToken) { + current = shared; + } else { + current = refreshed; + setAuthResult(current); + } + } + response = await send(current); + if (getAuthIdentityKey() !== operationIdentity) { + throw new AuthIdentityChangedError(); + } } - if (!retryRes.ok) { - throw new Error(`${retryRes.status}`); + if (!response.ok) throw new Error(`${response.status}`); + const rotated = (await response.json()) as IAuthResult; + if (!isSuccessfulAuth(rotated)) throw new Error("Unauthorized"); + const commitAuth = getAuthResult(); + if ( + getAuthIdentityKey(commitAuth) !== operationIdentity || + commitAuth?.refreshToken !== current.refreshToken + ) { + throw new AuthIdentityChangedError(); } + setAuthResult(rotated); + return rotated; + }); - return await parseJsonSafe(retryRes); - } +async function parseJsonSafe(res: Response): Promise { + const text = await res.text(); + return text ? (JSON.parse(text) as T) : (undefined as T); +} - if (localStorage.getItem("auth")) { - authResult = JSON.parse(localStorage.getItem("auth")!); - return await fetcher(input, init); +export default async function fetcher( + input: RequestInfo, + init?: RequestInit, +): Promise { + const currentAuth = getAuthResult(); + if (currentAuth) { + const method = (init?.method ?? "GET").toUpperCase(); + const bound = beginAuthBoundRequest( + mutationMethods.has(method), + init?.signal, + ); + authResult = currentAuth; + const send = (auth: IAuthResult) => + fetch(input, { + ...init, + signal: bound.signal, + headers: { + ...init?.headers, + Authorization: `Bearer ${auth.token}`, + }, + }); + + try { + let authForRequest = bound.auth; + let res = await send(authForRequest); + if (!bound.isCurrent()) throw new AuthIdentityChangedError(); + + if (res.status === 401) { + // Another request/tab may already have refreshed this same identity. + // Reuse that token, but never replay a request across session/profile. + const latest = getAuthResult(); + if (!hasSameIdentity(latest, bound.auth)) { + throw new AuthIdentityChangedError(); + } + if (latest!.token !== authForRequest.token) { + authForRequest = latest!; + } else { + if (!refreshPromise) { + refreshPromise = refreshAuthSession(bound.auth).finally(() => { + refreshPromise = null; + }); + } + authForRequest = await refreshPromise; + if ( + !bound.isCurrent() || + !hasSameIdentity(authForRequest, bound.auth) + ) { + throw new AuthIdentityChangedError(); + } + } + + // Re-check after refresh and again after the retry response. A remote + // profile switch during either await cancels instead of replaying. + if (!bound.isCurrent()) throw new AuthIdentityChangedError(); + res = await send(authForRequest); + if (!bound.isCurrent()) throw new AuthIdentityChangedError(); + if (res.status === 401) { + clearAuth(authForRequest.refreshToken); + throw new Error("Unauthorized"); + } + } + + if (!res.ok) throw new Error(`${res.status}`); + const result = await parseJsonSafe(res); + if (!bound.isCurrent()) throw new AuthIdentityChangedError(); + return result; + } catch (error) { + if ( + identityTransitionInProgress || + getAuthIdentityKey() !== bound.identityKey + ) { + throw new AuthIdentityChangedError(); + } + throw error; + } finally { + bound.dispose(); + } } // No auth available diff --git a/SecondDimensionWatcherReDive.Client/src/auth/sessionApi.ts b/SecondDimensionWatcherReDive.Client/src/auth/sessionApi.ts new file mode 100644 index 0000000..416366c --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/auth/sessionApi.ts @@ -0,0 +1,15 @@ +import { IAuthResult } from "./IAuthResult"; + +export const refreshJwtToken = async ( + oldToken: IAuthResult, +): Promise => { + const response = await fetch("/api/auth/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(oldToken), + }); + if (!response.ok) throw new Error(`${response.status}`); + return (await response.json()) as IAuthResult; +}; diff --git a/SecondDimensionWatcherReDive.Client/src/auth/utils.ts b/SecondDimensionWatcherReDive.Client/src/auth/utils.ts index 66a4b51..1bc2dc8 100644 --- a/SecondDimensionWatcherReDive.Client/src/auth/utils.ts +++ b/SecondDimensionWatcherReDive.Client/src/auth/utils.ts @@ -1,38 +1,87 @@ import { IAuthResult } from "./IAuthResult"; +import fetcher, { + clearAuthForSession, + getAuthResult, + rotateAuthenticatedSession, +} from "./httpClient"; -export const login = async (password: string): Promise => { - const response = await fetch("/api/auth/login", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ password }), - }); - return (await response.json()) as IAuthResult; -}; +export { refreshJwtToken } from "./sessionApi"; + +interface LoginOptions { + username?: string; + deviceName?: string; + profileName?: string; +} -export const refreshJwtToken = async ( - oldToken: IAuthResult, +export const login = async ( + password: string, + options: LoginOptions = {}, ): Promise => { - const response = await fetch("/api/auth/refresh", { + const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(oldToken), + body: JSON.stringify({ password, ...options }), }); + if (!response.ok) throw new Error(`${response.status}`); return (await response.json()) as IAuthResult; }; export const register = async ( password: string, + options: LoginOptions = {}, ): Promise => { const response = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ password }), + body: JSON.stringify({ password, ...options }), }); + if (!response.ok) throw new Error(`${response.status}`); return (await response.json()) as IAuthResult; }; + +export const switchProfile = (profileId: string, pin?: string) => + rotateAuthenticatedSession("/api/accounts/profiles/switch", (auth) => ({ + profileId, + pin: pin || null, + refreshToken: auth.refreshToken, + })); + +export const reauthenticate = (password: string) => + rotateAuthenticatedSession("/api/auth/reauthenticate", (auth) => ({ + password, + refreshToken: auth.refreshToken, + })); + +export const retryAfterReauthentication = async ( + operation: () => Promise, + promptMessage: string, +): Promise => { + try { + return await operation(); + } catch (error) { + if (!(error instanceof Error) || error.message !== "403") throw error; + const password = window.prompt(promptMessage); + if (!password) throw error; + await reauthenticate(password); + return operation(); + } +}; + +export const logout = async (): Promise => { + const sessionId = getAuthResult()?.sessionId; + try { + await fetcher("/api/auth/logout", { method: "POST" }); + } catch { + // Local logout must remain available if the session is already invalid or + // the server is unreachable. The server revocation above is best-effort. + } finally { + // A late logout from an old tab must not erase a newer login session from + // shared storage. Profile changes within this same session are still + // cleared because the server revocation applies to the whole session. + clearAuthForSession(sessionId); + } +}; diff --git a/SecondDimensionWatcherReDive.Client/src/chat/api.ts b/SecondDimensionWatcherReDive.Client/src/chat/api.ts index b819ea0..45e0b7a 100644 --- a/SecondDimensionWatcherReDive.Client/src/chat/api.ts +++ b/SecondDimensionWatcherReDive.Client/src/chat/api.ts @@ -1,39 +1,25 @@ -const API_BASE = "/api/chat"; +import fetcher from "../auth/httpClient"; -function getAuthHeaders(): HeadersInit { - const authStr = localStorage.getItem("auth"); - if (!authStr) return {}; - try { - const auth = JSON.parse(authStr); - return { Authorization: `Bearer ${auth.token}` }; - } catch { - return {}; - } -} +const API_BASE = "/api/chat"; export async function createConversation(title?: string) { - const res = await fetch(`${API_BASE}/conversations`, { + return await fetcher(`${API_BASE}/conversations`, { method: "POST", - headers: { "Content-Type": "application/json", ...getAuthHeaders() }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: title ?? null }), }); - if (!res.ok) throw new Error("Failed to create conversation"); - return res.json(); } export async function deleteConversation(id: string) { - const res = await fetch(`${API_BASE}/conversations/${id}`, { + await fetcher(`${API_BASE}/conversations/${id}`, { method: "DELETE", - headers: getAuthHeaders(), }); - if (!res.ok) throw new Error("Failed to delete conversation"); } export async function updateConversationTitle(id: string, title: string) { - const res = await fetch(`${API_BASE}/conversations/${id}`, { + await fetcher(`${API_BASE}/conversations/${id}`, { method: "PATCH", - headers: { "Content-Type": "application/json", ...getAuthHeaders() }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }); - if (!res.ok) throw new Error("Failed to update title"); } diff --git a/SecondDimensionWatcherReDive.Client/src/chat/useStreamingChat.ts b/SecondDimensionWatcherReDive.Client/src/chat/useStreamingChat.ts index 42487de..66fdcf2 100644 --- a/SecondDimensionWatcherReDive.Client/src/chat/useStreamingChat.ts +++ b/SecondDimensionWatcherReDive.Client/src/chat/useStreamingChat.ts @@ -1,4 +1,10 @@ -import { useCallback, useReducer } from "react"; +import { useCallback, useEffect, useReducer, useRef } from "react"; + +import { + AuthBoundRequest, + AuthIdentityChangedError, + beginAuthBoundRequest, +} from "../auth/httpClient"; interface StreamingToolCall { id: string; @@ -81,8 +87,7 @@ function reducer( return { ...state, contentBlocks: state.contentBlocks.map((block) => - block.type === "tool_call" && - block.toolCall.id === action.toolCallId + block.type === "tool_call" && block.toolCall.id === action.toolCallId ? { ...block, toolCall: { ...block.toolCall, result: action.result }, @@ -113,44 +118,43 @@ const initialState: StreamingState = { export function useStreamingChat() { const [state, dispatch] = useReducer(reducer, initialState); + const activeRequestRef = useRef(null); + const requestGenerationRef = useRef(0); const sendMessage = useCallback( async (conversationId: string, content: string, model?: string) => { + activeRequestRef.current?.abort(); + activeRequestRef.current?.dispose(); + const generation = requestGenerationRef.current + 1; + requestGenerationRef.current = generation; dispatch({ type: "start" }); - const authStr = localStorage.getItem("auth"); - if (!authStr) { - dispatch({ type: "error", message: "Not authenticated" }); - return; - } - - let token: string; - try { - token = JSON.parse(authStr).token; - } catch { - dispatch({ type: "error", message: "Invalid auth token" }); - return; - } - + let request: AuthBoundRequest | null = null; try { + request = beginAuthBoundRequest(true); + activeRequestRef.current = request; const response = await fetch( `/api/chat/conversations/${conversationId}/messages`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${request.auth.token}`, }, body: JSON.stringify({ content, model: model ?? null }), + signal: request.signal, }, ); + if (!request.isCurrent()) throw new AuthIdentityChangedError(); if (!response.ok) { const text = await response.text(); - dispatch({ - type: "error", - message: text || `HTTP ${response.status}`, - }); + if (requestGenerationRef.current === generation) { + dispatch({ + type: "error", + message: text || `HTTP ${response.status}`, + }); + } return; } @@ -166,6 +170,7 @@ export function useStreamingChat() { while (true) { const { done, value } = await reader.read(); + if (!request.isCurrent()) throw new AuthIdentityChangedError(); if (done) break; buffer += decoder.decode(value, { stream: true }); @@ -181,36 +186,48 @@ export function useStreamingChat() { const data = JSON.parse(line.slice(6)); switch (currentEvent) { case "text_delta": - dispatch({ type: "text_delta", text: data.text }); + if (requestGenerationRef.current === generation) { + dispatch({ type: "text_delta", text: data.text }); + } break; case "tool_call_begin": - dispatch({ - type: "tool_call_begin", - id: data.id, - name: data.name, - }); + if (requestGenerationRef.current === generation) { + dispatch({ + type: "tool_call_begin", + id: data.id, + name: data.name, + }); + } break; case "tool_call_delta": - dispatch({ - type: "tool_call_delta", - id: data.id, - argumentsDelta: data.arguments_delta, - }); + if (requestGenerationRef.current === generation) { + dispatch({ + type: "tool_call_delta", + id: data.id, + argumentsDelta: data.arguments_delta, + }); + } break; case "tool_result": - dispatch({ - type: "tool_result", - toolCallId: data.tool_call_id, - name: data.name, - result: data.result, - }); + if (requestGenerationRef.current === generation) { + dispatch({ + type: "tool_result", + toolCallId: data.tool_call_id, + name: data.name, + result: data.result, + }); + } break; case "finished": receivedFinished = true; - dispatch({ type: "finished" }); + if (requestGenerationRef.current === generation) { + dispatch({ type: "finished" }); + } break; case "error": - dispatch({ type: "error", message: data.message }); + if (requestGenerationRef.current === generation) { + dispatch({ type: "error", message: data.message }); + } break; } } catch { @@ -221,20 +238,49 @@ export function useStreamingChat() { } } - if (!receivedFinished) { + if (!receivedFinished && requestGenerationRef.current === generation) { dispatch({ type: "finished" }); } } catch (err) { - dispatch({ - type: "error", - message: err instanceof Error ? err.message : "Unknown error", - }); + if (requestGenerationRef.current !== generation) return; + if ( + err instanceof AuthIdentityChangedError || + (err instanceof DOMException && err.name === "AbortError") + ) { + dispatch({ type: "reset" }); + } else { + dispatch({ + type: "error", + message: err instanceof Error ? err.message : "Unknown error", + }); + } + } finally { + request?.dispose(); + if (activeRequestRef.current === request) { + activeRequestRef.current = null; + } } }, [], ); - const reset = useCallback(() => dispatch({ type: "reset" }), []); + const reset = useCallback(() => { + requestGenerationRef.current += 1; + activeRequestRef.current?.abort(); + activeRequestRef.current?.dispose(); + activeRequestRef.current = null; + dispatch({ type: "reset" }); + }, []); + + useEffect( + () => () => { + requestGenerationRef.current += 1; + activeRequestRef.current?.abort(); + activeRequestRef.current?.dispose(); + activeRequestRef.current = null; + }, + [], + ); return { ...state, sendMessage, reset }; } diff --git a/SecondDimensionWatcherReDive.Client/src/components/AnimationInfo.tsx b/SecondDimensionWatcherReDive.Client/src/components/AnimationInfo.tsx index 82691e1..f097db0 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/AnimationInfo.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/AnimationInfo.tsx @@ -30,6 +30,8 @@ import { retryInference, submitDownload, } from "../animation/utils"; +import { useAccess } from "../auth/hooks"; +import { retryAfterReauthentication } from "../auth/utils"; import { setPlaybackWatched } from "../playback/api"; import { usePlaybackStates } from "../playback/hooks"; import { formatBytes, formatFileSize } from "../utils/formatBytes"; @@ -126,7 +128,8 @@ const AutomationDispositionBadge: React.FC<{ }; const ActionButtons: React.FC<{ value: IAnimationInfo }> = ({ value }) => { - const { t } = useTranslation("animation"); + const { t } = useTranslation(["animation", "settings"]); + const { canContentWrite, isAdministrator } = useAccess(); const { data: status } = useAnimationDownloadStatus( value.isDownloadTracked && !value.isDownloadFinished ? value.id : null, ); @@ -134,6 +137,7 @@ const ActionButtons: React.FC<{ value: IAnimationInfo }> = ({ value }) => { const [isSheetOpen, setIsSheetOpen] = React.useState(false); const [isRetrying, setIsRetrying] = React.useState(false); const [isReidentifyingFiles, setIsReidentifyingFiles] = React.useState(false); + const [isCancelling, setIsCancelling] = React.useState(false); const [isUpdatingWatched, setIsUpdatingWatched] = React.useState(false); const { data: playbackStates, mutate: mutatePlaybackStates } = usePlaybackStates(value.isDownloadFinished ? value.id : undefined); @@ -142,8 +146,9 @@ const ActionButtons: React.FC<{ value: IAnimationInfo }> = ({ value }) => { playbackStates.length > 0 && playbackStates.every((state) => state.isWatched); - const showRetryItem = value.isAiProcessed; + const showRetryItem = isAdministrator && value.isAiProcessed; const showAiReidentifyItem = + isAdministrator && value.isDownloadFinished && value.animation != null && value.season != null && @@ -211,13 +216,35 @@ const ActionButtons: React.FC<{ value: IAnimationInfo }> = ({ value }) => { } }, [value.id, addToast, t]); + const onCancelDownload = React.useCallback( + async (removeFile: boolean) => { + if (isCancelling) return; + + setIsCancelling(true); + try { + const operation = () => cancelDownload(value.id, removeFile); + if (removeFile) { + await retryAfterReauthentication( + operation, + t("settings:system.reauthenticatePrompt"), + ); + } else { + await operation(); + } + } catch { + addToast({ title: t("toast.deleteFailed"), color: "danger" }); + } finally { + setIsCancelling(false); + } + }, + [addToast, isCancelling, t, value.id], + ); + const onDelete = React.useCallback(() => { if (window.confirm(t("confirm.deleteFile"))) { - cancelDownload(value.id, true).catch(() => - addToast({ title: t("toast.deleteFailed"), color: "danger" }), - ); + void onCancelDownload(true); } - }, [value.id, addToast, t]); + }, [onCancelDownload, t]); const onToggleAllWatched = React.useCallback(async () => { if (!playbackStates || playbackStates.length === 0) return; @@ -251,16 +278,20 @@ const ActionButtons: React.FC<{ value: IAnimationInfo }> = ({ value }) => { const hasOverflowItems = showRetryItem || showAiReidentifyItem || - (value.isDownloadTracked && !value.isDownloadFinished && status) || + (canContentWrite && + value.isDownloadTracked && + !value.isDownloadFinished && + status) || (value.isDownloadTracked && value.isDownloadFinished && - !value.isMediaLibraryImport); + !value.isMediaLibraryImport && + isAdministrator); return ( <>
{/* Primary action: icon-only button */} - {!value.isDownloadTracked ? ( + {!value.isDownloadTracked && canContentWrite ? ( ) : null} - {value.isDownloadTracked && !value.isDownloadFinished && status ? ( + {canContentWrite && + value.isDownloadTracked && + !value.isDownloadFinished && + status ? ( <> {status.state === "Downloading" ? ( +
+ {status.username} · {status.role} +
+ {status.profiles.map((profile) => ( + { + if (profile.id === status.profileId) return; + const pin = profile.hasPin + ? window.prompt(t("user.profilePin")) + : undefined; + if (profile.hasPin && pin === null) return; + void switchProfile(profile.id, pin || undefined).then(() => { + window.location.assign("/"); + }); + }} + > + + {profile.name} + + ))} + navigate("/account")}> + + {t("user.manageAccount")} + +
{t("user.language")}
@@ -209,7 +274,7 @@ const UserMenu: React.FC = () => { ))} - + void onLogout()}> {t("user.logout")}
@@ -220,9 +285,12 @@ const UserMenu: React.FC = () => { export const AppHeader: React.FC = () => { const { t } = useTranslation(); const { data: status } = useLoginStatus(); - const { data: incidents } = useIncidents({ take: 1 }); + const { data: incidents } = useIncidents({ + take: 1, + enabled: status?.role === "Admin", + }); const navigate = useNavigate(); - const items = createNavItems(incidents?.openCount); + const items = createNavItems(status?.role, incidents?.openCount); return (
@@ -250,7 +318,7 @@ export const AppHeader: React.FC = () => {
{status ? ( - + ) : (
diff --git a/SecondDimensionWatcherReDive.Client/src/components/chat/ChatMessageList.tsx b/SecondDimensionWatcherReDive.Client/src/components/chat/ChatMessageList.tsx index ce6352e..a913123 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/chat/ChatMessageList.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/chat/ChatMessageList.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { ChatMessageData } from "../../chat/types"; import { StreamingContentBlock } from "../../chat/useStreamingChat"; -import { AssistantGroup, UserBubble, StreamingMessage } from "./ChatMessage"; +import { AssistantGroup, StreamingMessage, UserBubble } from "./ChatMessage"; interface ChatMessageListProps { messages: ChatMessageData[]; @@ -13,13 +13,12 @@ interface ChatMessageListProps { } /** Group consecutive non-user messages into runs. Each user message is its own group. */ -function groupMessages( - messages: ChatMessageData[], -): { type: "user"; message: ChatMessageData }[] | { type: "assistant"; messages: ChatMessageData[] }[] { - const groups: ( - | { type: "user"; message: ChatMessageData } - | { type: "assistant"; messages: ChatMessageData[] } - )[] = []; +type MessageGroup = + | { type: "user"; message: ChatMessageData } + | { type: "assistant"; messages: ChatMessageData[] }; + +function groupMessages(messages: ChatMessageData[]): MessageGroup[] { + const groups: MessageGroup[] = []; for (const msg of messages) { if (msg.role === "system") continue; @@ -61,31 +60,32 @@ export const ChatMessageList: React.FC = ({

{t("emptyTitle")}

-

- {t("emptyHelp")} -

+

{t("emptyHelp")}

)} - {groups.map((group, i) => + {groups.map((group) => group.type === "user" ? ( ) : ( - + ), )} {pendingUserMessage && !messages.some( (m) => m.role === "user" && m.content === pendingUserMessage, ) && ( -
-
-
- {pendingUserMessage} +
+
+
+ {pendingUserMessage} +
-
- )} + )} {isStreaming && ( { const { t } = useTranslation(["settings", "errors"]); const { data, error, mutate } = useWebDavTokens(); + const { data: users } = useUsers(true); const { addToast } = useToast(); const [username, setUsername] = React.useState(""); const [description, setDescription] = React.useState(""); + const [virtualRoot, setVirtualRoot] = React.useState("/"); + const [expiresAt, setExpiresAt] = React.useState(""); + const [userId, setUserId] = React.useState(""); const [creating, setCreating] = React.useState(false); const [created, setCreated] = React.useState(null); @@ -38,9 +44,18 @@ export const WebDavSettingsSection: React.FC = () => { if (creating) return; setCreating(true); try { - const response = await createWebDavToken( - username.trim() || undefined, - description.trim() || undefined, + const response = await retryAfterReauthentication( + () => + createWebDavToken( + username.trim() || undefined, + description.trim() || undefined, + virtualRoot.trim() || "/", + expiresAt + ? new Date(`${expiresAt}T23:59:59`).toISOString() + : undefined, + userId || undefined, + ), + t("settings:system.reauthenticatePrompt"), ); setCreated(response); setUsername(""); @@ -58,7 +73,17 @@ export const WebDavSettingsSection: React.FC = () => { } finally { setCreating(false); } - }, [addToast, creating, description, mutate, t, username]); + }, [ + addToast, + creating, + description, + expiresAt, + mutate, + t, + username, + userId, + virtualRoot, + ]); const remove = React.useCallback( async (token: IWebDavToken) => { @@ -71,7 +96,10 @@ export const WebDavSettingsSection: React.FC = () => { ) return; try { - await deleteWebDavToken(token.id); + await retryAfterReauthentication( + () => deleteWebDavToken(token.id), + t("settings:system.reauthenticatePrompt"), + ); await mutate(); addToast({ title: t("settings:webdav.toast.deleted"), @@ -118,6 +146,29 @@ export const WebDavSettingsSection: React.FC = () => { name: t("settings:webdav.list.columns.description"), render: (value: string | undefined) => value || "-", }, + { + field: "userId", + name: t("settings:webdav.list.columns.user"), + render: (value: string) => + users?.find((user) => user.id === value)?.username ?? value, + }, + { + field: "virtualRoot", + name: t("settings:webdav.list.columns.virtualRoot"), + render: (value: string) => ( + {value} + ), + }, + { + field: "expiresAt", + name: t("settings:webdav.list.columns.expiresAt"), + render: (value: string | undefined, item) => + item.revokedAt + ? t("settings:webdav.list.revoked") + : value + ? new Date(value).toLocaleString() + : "-", + }, { field: "createdAt", name: t("settings:webdav.list.columns.createdAt"), @@ -125,19 +176,20 @@ export const WebDavSettingsSection: React.FC = () => { }, { name: t("settings:webdav.list.columns.actions"), - render: (_value, item) => ( - - ), + render: (_value, item) => + item.revokedAt ? null : ( + + ), width: "60px", }, ]; @@ -156,7 +208,23 @@ export const WebDavSettingsSection: React.FC = () => { icon={} title={t("settings:webdav.create.title")} > -
+
+ + + { onChange={(event) => setUsername(event.target.value)} /> + + setVirtualRoot(event.target.value)} + /> + + + setExpiresAt(event.target.value)} + /> + { @@ -10,32 +14,29 @@ export const useVfsList = (path: string) => { ); }; -function getAuthHeaders(): HeadersInit { - const authStr = localStorage.getItem("auth"); - if (!authStr) return {}; - try { - const auth = JSON.parse(authStr); - return { Authorization: `Bearer ${auth.token}` }; - } catch { - return {}; - } -} - export async function downloadVfsFile( path: string, fileName: string, ): Promise { - const res = await fetch(`/api/vfs/read?path=${encodeURIComponent(path)}`, { - headers: getAuthHeaders(), - }); - if (!res.ok) throw new Error(`${res.status}`); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); + const request = beginAuthBoundRequest(); + try { + const res = await fetch(`/api/vfs/read?path=${encodeURIComponent(path)}`, { + headers: { Authorization: `Bearer ${request.auth.token}` }, + signal: request.signal, + }); + if (!request.isCurrent()) throw new AuthIdentityChangedError(); + if (!res.ok) throw new Error(`${res.status}`); + const blob = await res.blob(); + if (!request.isCurrent()) throw new AuthIdentityChangedError(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } finally { + request.dispose(); + } } diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/accounts.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/accounts.json new file mode 100644 index 0000000..c88b32f --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/accounts.json @@ -0,0 +1,37 @@ +{ + "title": "Account and profiles", + "profiles": "Profiles", + "active": "Active", + "available": "Available", + "pinProtected": "PIN protected", + "noPin": "No PIN", + "editProfile": "Edit name, avatar or PIN", + "switchProfile": "Switch profile", + "createProfile": "Create profile", + "profileName": "Profile name", + "avatar": "Avatar URL", + "pinOptional": "PIN (optional, 4–8 digits)", + "create": "Create", + "pinPrompt": "Enter this profile's PIN", + "replacePinPrompt": "Do you also want to replace or clear this profile's PIN?", + "currentPin": "Enter the current PIN", + "newPin": "Enter a new 4–8 digit PIN, or leave empty to clear it", + "reauthPrompt": "Enter your account password to confirm this sensitive action", + "failed": "The operation failed", + "mySessions": "Your login sessions", + "allSessions": "All login sessions", + "unknownDevice": "Unknown device", + "current": "Current session", + "revoked": "Revoked", + "revoke": "Revoke", + "users": "Household users", + "createUser": "Create user", + "username": "Username", + "password": "Password", + "role": "Role", + "enable": "Enable", + "disable": "Disable", + "deviceTokens": "Device access tokens", + "deviceTokensHelp": "Issue path-scoped, expiring credentials for WebDAV and VFS clients.", + "manageDeviceTokens": "Manage device tokens" +} diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/animation.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/animation.json index 3816b50..a9754bb 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/animation.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/animation.json @@ -56,6 +56,7 @@ "retryDownload": "Retry download", "pause": "Pause", "resume": "Resume", + "cancel": "Cancel task", "browse": "Browse files", "markAllWatched": "Mark every video in this release watched", "markAllUnwatched": "Mark every video in this release unwatched", @@ -70,6 +71,7 @@ "confirm": { "deleteFile": "Delete the downloaded files?", "cancelAndDelete": "Cancel the download and delete the files?", + "cancel": "Cancel the download task? Existing files will be retained.", "forceAiReidentifyFiles": "This will ignore regex rules, use AI to re-identify filenames, and replace the current virtual file mappings. Continue?" }, "toast": { diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/auth.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/auth.json index c739ce1..ce45c47 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/auth.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/auth.json @@ -1,6 +1,8 @@ { "setupTitle": "Set a password", "setupHelp": "This is your first time using SDW Re:Dive. Please choose a password.", + "username": "Username", + "profileName": "Initial profile name", "password": "Password", "passwordPlaceholder": "Enter password", "repeatPassword": "Confirm password", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/common.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/common.json index 541cb5e..83e996d 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/common.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/common.json @@ -15,6 +15,8 @@ }, "user": { "account": "Account", + "manageAccount": "Manage account", + "profilePin": "Enter this profile's PIN", "language": "Language", "logout": "Sign out", "login": "Sign in" diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json index 9c91e39..9d6ee88 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json @@ -2,6 +2,7 @@ "pageTitle": "System settings", "system": { "pageDescription": "Configure AI execution, external services, media processing, health monitoring, and access protocols. Secret values are never displayed, and leaving a new value blank preserves the current setting.", + "reauthenticatePrompt": "Enter your account password to confirm this sensitive action", "loadFailed": "System settings could not be loaded. Check the service and try again.", "retry": "Reload", "navigation": { @@ -242,11 +243,15 @@ "intro": "Issue per-device WebDAV username and access-token pairs. Tokens are stored as BCrypt hashes in the database and the plaintext is displayed exactly once at creation time. Revoke any pair at any time.", "create": { "title": "Create a new credential", + "userLabel": "Credential owner", + "currentUser": "Current administrator", "usernameLabel": "Username (optional)", "usernamePlaceholder": "Leave blank to auto-generate", "usernameHelp": "Usernames may contain letters, digits, '.', '_' or '-' and must be 3-32 characters long.", "descriptionLabel": "Note (optional)", "descriptionPlaceholder": "e.g. living-room Mac mini", + "virtualRootLabel": "Visible virtual root", + "expiresAtLabel": "Expires on (optional)", "submit": "Generate" }, "created": { @@ -263,12 +268,16 @@ }, "columns": { "username": "Username", + "user": "Owner", "description": "Note", + "virtualRoot": "Visible root", + "expiresAt": "Expiry / status", "createdAt": "Created", "actions": "Actions" }, "deleteConfirm": "Revoke credential \"{{username}}\"? This cannot be undone.", - "deleteAria": "Revoke {{username}}" + "deleteAria": "Revoke {{username}}", + "revoked": "Revoked" }, "toast": { "created": "Credential generated", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/accounts.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/accounts.json new file mode 100644 index 0000000..4c885f0 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/accounts.json @@ -0,0 +1,37 @@ +{ + "title": "アカウントとプロフィール", + "profiles": "プロフィール", + "active": "使用中", + "available": "切り替え可能", + "pinProtected": "PIN 保護あり", + "noPin": "PIN なし", + "editProfile": "名前・アバター・PIN を編集", + "switchProfile": "プロフィールを切り替え", + "createProfile": "プロフィールを作成", + "profileName": "プロフィール名", + "avatar": "アバター URL", + "pinOptional": "PIN(任意、4~8 桁)", + "create": "作成", + "pinPrompt": "このプロフィールの PIN を入力してください", + "replacePinPrompt": "このプロフィールの PIN も変更または解除しますか?", + "currentPin": "現在の PIN を入力してください", + "newPin": "新しい 4~8 桁の PIN(空欄で解除)", + "reauthPrompt": "この重要な操作を確認するため、アカウントのパスワードを入力してください", + "failed": "操作に失敗しました", + "mySessions": "ログインセッション", + "allSessions": "すべてのログインセッション", + "unknownDevice": "不明なデバイス", + "current": "現在のセッション", + "revoked": "失効済み", + "revoke": "失効", + "users": "世帯ユーザー", + "createUser": "ユーザーを作成", + "username": "ユーザー名", + "password": "パスワード", + "role": "ロール", + "enable": "有効化", + "disable": "無効化", + "deviceTokens": "デバイスアクセストークン", + "deviceTokensHelp": "WebDAV / VFS クライアント向けにパスと期限を限定した資格情報を発行します。", + "manageDeviceTokens": "デバイストークンを管理" +} diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/animation.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/animation.json index 2184585..4ef7b29 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/animation.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/animation.json @@ -53,6 +53,7 @@ "retryDownload": "ダウンロードを再試行", "pause": "一時停止", "resume": "再開", + "cancel": "タスクを中止", "browse": "ファイルを開く", "markAllWatched": "このリリースの全動画を視聴済みにする", "markAllUnwatched": "このリリースの全動画を未視聴に戻す", @@ -67,6 +68,7 @@ "confirm": { "deleteFile": "ダウンロード済みのファイルを削除しますか?", "cancelAndDelete": "ダウンロードを中止してファイルを削除しますか?", + "cancel": "ダウンロードタスクを中止しますか?既存ファイルは保持されます。", "forceAiReidentifyFiles": "正規表現ルールを無視して AI でファイル名を再識別し、現在の仮想ファイルマッピングを置き換えます。続行しますか?" }, "toast": { diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/auth.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/auth.json index a5c92f7..7394712 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/auth.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/auth.json @@ -1,6 +1,8 @@ { "setupTitle": "パスワードを設定してください", "setupHelp": "SDW Re:Dive をはじめてご利用になります。パスワードを設定してください。", + "username": "ユーザー名", + "profileName": "最初のプロフィール名", "password": "パスワード", "passwordPlaceholder": "パスワードを入力", "repeatPassword": "パスワードを再入力", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/common.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/common.json index cd05358..f78a876 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/common.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/common.json @@ -15,6 +15,8 @@ }, "user": { "account": "アカウント", + "manageAccount": "アカウント管理", + "profilePin": "このプロフィールの PIN を入力してください", "language": "言語", "logout": "ログアウト", "login": "ログイン" diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json index 40eebfe..3e2eec7 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json @@ -2,6 +2,7 @@ "pageTitle": "システム設定", "system": { "pageDescription": "AI 実行エンジン、外部サービス、メディア処理、ヘルス監視、アクセスプロトコルを設定します。シークレット値は表示されず、新しい値を空欄にすると現在の設定が保持されます。", + "reauthenticatePrompt": "この重要な操作を確認するため、アカウントのパスワードを入力してください", "loadFailed": "システム設定を読み込めませんでした。サービスを確認して再試行してください。", "retry": "再読み込み", "navigation": { @@ -242,11 +243,15 @@ "intro": "デバイスごとに個別の WebDAV ユーザー名とアクセストークンを発行できます。トークンはデータベースに BCrypt ハッシュで保存され、平文は作成時に一度だけ表示されます。いつでも失効可能です。", "create": { "title": "新しい認証情報を作成", + "userLabel": "認証情報の所有者", + "currentUser": "現在の管理者", "usernameLabel": "ユーザー名(任意)", "usernamePlaceholder": "空欄で自動生成", "usernameHelp": "ユーザー名は英数字、ドット、アンダースコア、ハイフンのみ、3-32 文字。", "descriptionLabel": "メモ(任意)", "descriptionPlaceholder": "例:リビングの Mac mini", + "virtualRootLabel": "表示する仮想ルート", + "expiresAtLabel": "有効期限(任意)", "submit": "生成" }, "created": { @@ -263,12 +268,16 @@ }, "columns": { "username": "ユーザー名", + "user": "所有者", "description": "メモ", + "virtualRoot": "表示ルート", + "expiresAt": "期限 / 状態", "createdAt": "作成日時", "actions": "操作" }, "deleteConfirm": "認証情報「{{username}}」を失効させますか?この操作は取り消せません。", - "deleteAria": "{{username}} を失効" + "deleteAria": "{{username}} を失効", + "revoked": "失効済み" }, "toast": { "created": "認証情報を作成しました", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/accounts.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/accounts.json new file mode 100644 index 0000000..ebcf0aa --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/accounts.json @@ -0,0 +1,37 @@ +{ + "title": "账户与档案", + "profiles": "档案", + "active": "当前使用", + "available": "可切换", + "pinProtected": "受 PIN 保护", + "noPin": "无 PIN", + "editProfile": "编辑名称、头像或 PIN", + "switchProfile": "切换档案", + "createProfile": "新建档案", + "profileName": "档案名称", + "avatar": "头像 URL", + "pinOptional": "PIN(可选,4–8 位数字)", + "create": "创建", + "pinPrompt": "请输入此档案的 PIN", + "replacePinPrompt": "是否同时重设或清除此档案的 PIN?", + "currentPin": "请输入当前 PIN", + "newPin": "输入新的 4–8 位 PIN,留空则清除", + "reauthPrompt": "请输入账户密码以确认此敏感操作", + "failed": "操作失败", + "mySessions": "你的登录会话", + "allSessions": "所有登录会话", + "unknownDevice": "未知设备", + "current": "当前会话", + "revoked": "已撤销", + "revoke": "撤销", + "users": "家庭用户", + "createUser": "创建用户", + "username": "用户名", + "password": "密码", + "role": "角色", + "enable": "启用", + "disable": "禁用", + "deviceTokens": "设备访问令牌", + "deviceTokensHelp": "为 WebDAV 和 VFS 客户端签发限定路径且会过期的凭据。", + "manageDeviceTokens": "管理设备令牌" +} diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/animation.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/animation.json index 1858136..ad00d5b 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/animation.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/animation.json @@ -53,6 +53,7 @@ "retryDownload": "重试下载", "pause": "暂停", "resume": "恢复", + "cancel": "取消任务", "browse": "浏览文件", "markAllWatched": "将此版本的全部视频标记为已看", "markAllUnwatched": "将此版本的全部视频标记为未看", @@ -67,6 +68,7 @@ "confirm": { "deleteFile": "确定要删除已下载的文件吗?", "cancelAndDelete": "确定要取消下载并删除文件吗?", + "cancel": "确定要取消下载任务吗?已有文件会保留。", "forceAiReidentifyFiles": "将忽略正则规则并使用 AI 重新识别文件名,现有虚拟文件映射将被替换。确定继续吗?" }, "toast": { diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/auth.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/auth.json index f214d68..7bf5678 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/auth.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/auth.json @@ -1,6 +1,8 @@ { "setupTitle": "请设置密码", "setupHelp": "您是第一次使用二次元观测器,请设置密码。", + "username": "用户名", + "profileName": "初始档案名称", "password": "密码", "passwordPlaceholder": "请输入密码", "repeatPassword": "重复密码", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/common.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/common.json index 5ffff49..8ba2336 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/common.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/common.json @@ -15,6 +15,8 @@ }, "user": { "account": "账户", + "manageAccount": "管理账户", + "profilePin": "请输入此档案的 PIN", "language": "语言", "logout": "注销", "login": "登录" diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json index 1d101be..f26e5b3 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json @@ -2,6 +2,7 @@ "pageTitle": "系统设置", "system": { "pageDescription": "配置 AI 执行引擎、外部服务、媒体处理、健康监控和访问协议。敏感值不会回显,未填写新值时会保留当前配置。", + "reauthenticatePrompt": "请输入账户密码以确认此敏感操作", "loadFailed": "无法加载系统设置,请检查服务状态后重试。", "retry": "重新加载", "navigation": { @@ -242,11 +243,15 @@ "intro": "在这里为每台设备生成独立的 WebDAV 用户名和访问令牌,可随时撤销。访问令牌在数据库中以 BCrypt 哈希保存,明文仅在创建时显示一次。", "create": { "title": "创建新凭据", + "userLabel": "凭据归属用户", + "currentUser": "当前管理员", "usernameLabel": "用户名(可选)", "usernamePlaceholder": "留空则自动生成", "usernameHelp": "用户名仅允许字母、数字、点、下划线、连字符,长度 3-32。", "descriptionLabel": "备注(可选)", "descriptionPlaceholder": "例如:客厅 Mac mini", + "virtualRootLabel": "可见虚拟根目录", + "expiresAtLabel": "到期日期(可选)", "submit": "生成" }, "created": { @@ -263,12 +268,16 @@ }, "columns": { "username": "用户名", + "user": "归属", "description": "备注", + "virtualRoot": "可见根目录", + "expiresAt": "到期 / 状态", "createdAt": "创建时间", "actions": "操作" }, "deleteConfirm": "确认撤销凭据「{{username}}」吗?此操作不可恢复。", - "deleteAria": "撤销 {{username}}" + "deleteAria": "撤销 {{username}}", + "revoked": "已撤销" }, "toast": { "created": "凭据已生成", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/resources.ts b/SecondDimensionWatcherReDive.Client/src/i18n/resources.ts index 0533282..1bb8bc0 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/resources.ts +++ b/SecondDimensionWatcherReDive.Client/src/i18n/resources.ts @@ -1,3 +1,4 @@ +import enAccounts from "./locales/en/accounts.json"; import enAnimation from "./locales/en/animation.json"; import enAuth from "./locales/en/auth.json"; import enChat from "./locales/en/chat.json"; @@ -11,6 +12,7 @@ import enPlayer from "./locales/en/player.json"; import enSeason from "./locales/en/season.json"; import enSettings from "./locales/en/settings.json"; import enTasks from "./locales/en/tasks.json"; +import jaAccounts from "./locales/ja/accounts.json"; import jaAnimation from "./locales/ja/animation.json"; import jaAuth from "./locales/ja/auth.json"; import jaChat from "./locales/ja/chat.json"; @@ -24,6 +26,7 @@ import jaPlayer from "./locales/ja/player.json"; import jaSeason from "./locales/ja/season.json"; import jaSettings from "./locales/ja/settings.json"; import jaTasks from "./locales/ja/tasks.json"; +import zhCnAccounts from "./locales/zh-CN/accounts.json"; import zhCnAnimation from "./locales/zh-CN/animation.json"; import zhCnAuth from "./locales/zh-CN/auth.json"; import zhCnChat from "./locales/zh-CN/chat.json"; @@ -41,6 +44,7 @@ import zhCnTasks from "./locales/zh-CN/tasks.json"; export const resources = { "zh-cn": { common: zhCnCommon, + accounts: zhCnAccounts, auth: zhCnAuth, errors: zhCnErrors, animation: zhCnAnimation, @@ -56,6 +60,7 @@ export const resources = { }, en: { common: enCommon, + accounts: enAccounts, auth: enAuth, errors: enErrors, animation: enAnimation, @@ -71,6 +76,7 @@ export const resources = { }, ja: { common: jaCommon, + accounts: jaAccounts, auth: jaAuth, errors: jaErrors, animation: jaAnimation, diff --git a/SecondDimensionWatcherReDive.Client/src/incidents/hooks.ts b/SecondDimensionWatcherReDive.Client/src/incidents/hooks.ts index 06d3a02..3228504 100644 --- a/SecondDimensionWatcherReDive.Client/src/incidents/hooks.ts +++ b/SecondDimensionWatcherReDive.Client/src/incidents/hooks.ts @@ -8,6 +8,7 @@ export interface IncidentQuery { skip?: number; take?: number; includeResolved?: boolean; + enabled?: boolean; } export const incidentListKey = ({ @@ -26,6 +27,10 @@ export const incidentListKey = ({ }; export const useIncidents = (query: IncidentQuery = {}) => - useSWR(incidentListKey(query), fetcher, { - refreshInterval: 15_000, - }); + useSWR( + query.enabled === false ? null : incidentListKey(query), + fetcher, + { + refreshInterval: 15_000, + }, + ); diff --git a/SecondDimensionWatcherReDive.Client/src/pages/AccountPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/AccountPage.tsx new file mode 100644 index 0000000..9d99321 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/pages/AccountPage.tsx @@ -0,0 +1,454 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; +import { mutate as mutateAll } from "swr"; + +import { KeyRound, Monitor, Plus, Shield, UserRound } from "lucide-react"; + +import { + createProfile, + createUser, + revokeSession, + updateProfile, + updateUserAccess, +} from "../accounts/api"; +import { + useAllSessions, + useProfiles, + useSessions, + useUsers, +} from "../accounts/hooks"; +import { IAccountSession } from "../accounts/types"; +import { IAuthProfile, UserRole } from "../auth/IAuthResult"; +import { useLoginStatus } from "../auth/hooks"; +import { clearAuthForSession } from "../auth/httpClient"; +import { reauthenticate, switchProfile } from "../auth/utils"; +import { Button } from "../components/ui/Button"; +import { Card } from "../components/ui/Card"; +import { FormRow } from "../components/ui/FormRow"; +import { Input } from "../components/ui/Input"; +import { PasswordInput } from "../components/ui/PasswordInput"; +import { PageTemplate } from "./PageTemplate"; + +const roles: UserRole[] = ["Admin", "Member", "Viewer"]; + +export const AccountPage: React.FC = () => { + const { t } = useTranslation("accounts"); + const navigate = useNavigate(); + const { data: status, mutate: mutateStatus } = useLoginStatus(); + const isAdmin = status?.role === "Admin"; + const canCreateProfile = + status?.role === "Admin" || status?.role === "Member"; + const { data: profiles, mutate: mutateProfiles } = useProfiles(); + const { data: sessions, mutate: mutateSessions } = useSessions(); + const { data: users, mutate: mutateUsers } = useUsers(isAdmin); + const { data: allSessions, mutate: mutateAllSessions } = + useAllSessions(isAdmin); + + const [profileName, setProfileName] = React.useState(""); + const [profileAvatar, setProfileAvatar] = React.useState(""); + const [profilePin, setProfilePin] = React.useState(""); + const [username, setUsername] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [newUserProfile, setNewUserProfile] = React.useState("Home"); + const [newUserRole, setNewUserRole] = React.useState("Member"); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + const run = React.useCallback( + async (operation: () => Promise) => { + if (busy) return; + setBusy(true); + setError(null); + try { + await operation(); + } catch (operationError) { + setError( + operationError instanceof Error + ? operationError.message + : t("failed"), + ); + } finally { + setBusy(false); + } + }, + [busy, t], + ); + + const stepUp = React.useCallback(async (): Promise => { + const value = window.prompt(t("reauthPrompt")); + if (!value) return false; + await reauthenticate(value); + await mutateStatus(); + return true; + }, [mutateStatus, t]); + + const activate = (profile: IAuthProfile) => + run(async () => { + const pin = profile.hasPin ? window.prompt(t("pinPrompt")) : undefined; + if (profile.hasPin && pin === null) return; + await switchProfile(profile.id, pin || undefined); + await mutateAll(() => true, undefined, { revalidate: false }); + window.location.assign("/"); + }); + + const saveCurrentProfile = (profile: IAuthProfile) => + run(async () => { + const name = window.prompt(t("profileName"), profile.name); + if (!name) return; + const avatar = window.prompt(t("avatar"), profile.avatar ?? ""); + if (avatar === null) return; + const replacePin = window.confirm(t("replacePinPrompt")); + let currentPin: string | undefined; + let pin: string | undefined; + if (replacePin) { + if (profile.hasPin) { + const value = window.prompt(t("currentPin")); + if (value === null) return; + currentPin = value; + } else if (!(await stepUp())) { + return; + } + const value = window.prompt(t("newPin")); + if (value === null) return; + pin = value; + } + await updateProfile(profile.id, { + name, + avatar: avatar || undefined, + currentPin, + pin, + replacePin, + }); + await Promise.all([mutateProfiles(), mutateStatus()]); + }); + + const addProfile = () => + run(async () => { + await createProfile({ + name: profileName, + avatar: profileAvatar || undefined, + pin: profilePin || undefined, + }); + setProfileName(""); + setProfileAvatar(""); + setProfilePin(""); + await Promise.all([mutateProfiles(), mutateStatus()]); + }); + + const removeSession = (session: IAccountSession, asAdmin = false) => + run(async () => { + if (asAdmin && !(await stepUp())) return; + await revokeSession(session.id, asAdmin); + if (session.isCurrent) { + if (clearAuthForSession(session.id)) { + navigate("/login", { replace: true }); + } + return; + } + await Promise.all([mutateSessions(), mutateAllSessions()]); + }); + + const addUser = () => + run(async () => { + if (!(await stepUp())) return; + await createUser({ + username, + password, + role: newUserRole, + profileName: newUserProfile, + }); + setUsername(""); + setPassword(""); + await mutateUsers(); + }); + + return ( + +
+

+ {t("title")} +

+

+ {status?.username} · {status?.role} +

+ {error ?

{error}

: null} +
+ +
+

+ {t("profiles")} +

+
+ {profiles?.map((profile) => { + const active = profile.id === status?.profileId; + return ( + + ) : ( + + ) + } + title={profile.name} + description={`${active ? t("active") : t("available")} · ${ + profile.hasPin ? t("pinProtected") : t("noPin") + }`} + footer={ + active ? ( + canCreateProfile ? ( + + ) : null + ) : ( + + ) + } + /> + ); + })} +
+ + {canCreateProfile ? ( + } + title={t("createProfile")} + > +
+ + setProfileName(event.target.value)} + /> + + + setProfileAvatar(event.target.value)} + /> + + + setProfilePin(event.target.value)} + /> + + + + +
+
+ ) : null} +
+ + void removeSession(session)} + /> + + {isAdmin ? ( + <> +
+

+ {t("users")} +

+ } title={t("createUser")}> +
+ + setUsername(event.target.value)} + /> + + + setPassword(event.target.value)} + /> + + + setNewUserProfile(event.target.value)} + /> + + + + + + + +
+
+
+ {users?.map((user) => ( +
+
+
+ {user.username} +
+
+ {user.profiles.map((profile) => profile.name).join(", ")} +
+
+ + +
+ ))} +
+
+ + void removeSession(session, true)} + /> + + } + title={t("deviceTokens")} + > +

{t("deviceTokensHelp")}

+ +
+ + ) : null} +
+ ); +}; + +const SessionSection: React.FC<{ + title: string; + sessions?: IAccountSession[]; + busy: boolean; + onRevoke: (session: IAccountSession) => void; +}> = ({ title, sessions, busy, onRevoke }) => { + const { t } = useTranslation("accounts"); + return ( +
+

{title}

+
+ {sessions?.map((session) => ( +
+ +
+
+ {session.deviceName || t("unknownDevice")} + {session.isCurrent ? ` · ${t("current")}` : ""} +
+
+ {session.username} / {session.profileName} ·{" "} + {new Date(session.lastSeenAt).toLocaleString()} + {session.revokedAt ? ` · ${t("revoked")}` : ""} +
+
+ {!session.revokedAt ? ( + + ) : null} +
+ ))} +
+
+ ); +}; diff --git a/SecondDimensionWatcherReDive.Client/src/pages/ChatPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/ChatPage.tsx index 5fd1877..5829ca1 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/ChatPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/ChatPage.tsx @@ -4,6 +4,7 @@ import { useNavigate, useParams } from "react-router"; import { MessageSquare } from "lucide-react"; +import { subscribeToAuthChanges } from "../auth/httpClient"; import { createConversation, deleteConversation } from "../chat/api"; import { useChatModels, @@ -48,6 +49,17 @@ export const ChatPage: React.FC = () => { reset: resetStreaming, } = useStreamingChat(); + useEffect( + () => + subscribeToAuthChanges(({ auth, profileChanged }) => { + if (!auth || profileChanged) { + setPendingUserMessage(null); + resetStreaming(); + } + }), + [resetStreaming], + ); + // Sync URL param with selected conversation useEffect(() => { const fromUrl = conversationId ?? null; diff --git a/SecondDimensionWatcherReDive.Client/src/pages/FeedsPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/FeedsPage.tsx index e689326..106f079 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/FeedsPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/FeedsPage.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { AlertTriangle, Plus, SlidersHorizontal, Trash2 } from "lucide-react"; +import { useAccess } from "../auth/hooks"; import { SubscriptionPolicyModeBadge, SubscriptionPolicySheet, @@ -24,6 +25,7 @@ import { PageTemplate } from "./PageTemplate"; export const FeedsPage: React.FC = () => { const { t } = useTranslation(["feeds", "errors"]); + const { canContentWrite } = useAccess(); const { data: feeds, error, mutate } = useFeeds(); const { data: policies, @@ -115,30 +117,31 @@ export const FeedsPage: React.FC = () => { }, { name: t("feeds:columns.actions"), - render: (_value: any, item: IFeed) => ( -
- - -
- ), + render: (_value: any, item: IFeed) => + canContentWrite ? ( +
+ + +
+ ) : null, width: "190px", }, ]; @@ -150,28 +153,30 @@ export const FeedsPage: React.FC = () => {

{t("feeds:manualSubscribe")}

-
- - setUrl(e.target.value)} - /> - - - setName(e.target.value)} - /> - - - - -
+ {canContentWrite ? ( +
+ + setUrl(e.target.value)} + /> + + + setName(e.target.value)} + /> + + + + +
+ ) : null}
@@ -201,16 +206,18 @@ export const FeedsPage: React.FC = () => { /> ) : null}
- { - if (!open) setSelectedFeed(null); - }} - onPolicyChanged={() => mutatePolicies()} - /> + {canContentWrite ? ( + { + if (!open) setSelectedFeed(null); + }} + onPolicyChanged={() => mutatePolicies()} + /> + ) : null} ); }; diff --git a/SecondDimensionWatcherReDive.Client/src/pages/LoginPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/LoginPage.tsx index 87bed60..b9c8e7f 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/LoginPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/LoginPage.tsx @@ -8,6 +8,7 @@ import { setAuthResult } from "../auth/httpClient"; import { login, register } from "../auth/utils"; import { Button } from "../components/ui/Button"; import { FormRow } from "../components/ui/FormRow"; +import { Input } from "../components/ui/Input"; import { PasswordInput } from "../components/ui/PasswordInput"; import { PageTemplate } from "./PageTemplate"; @@ -16,6 +17,8 @@ export const LoginPage: React.FC = () => { const { data: registerInfo } = useAllowRegister(); const { data: status } = useLoginStatus(); const [password, setPassword] = React.useState(""); + const [username, setUsername] = React.useState("admin"); + const [profileName, setProfileName] = React.useState("Home"); const [passwordConfirm, setPasswordConfirm] = React.useState(""); const [loginFailed, setLoginFailed] = React.useState(false); const [registerFailed, setRegisterFailed] = React.useState(false); @@ -39,10 +42,14 @@ export const LoginPage: React.FC = () => { setIsSubmitting(true); setRegisterFailed(false); try { - const r = await register(password); + const r = await register(password, { + username, + profileName, + deviceName: navigator.userAgent, + }); if (r?.success) { setAuthResult(r); - await mutate("/api/auth/verify", true, { revalidate: false }); + await mutate("/api/auth/verify"); navigate("/"); } else { setRegisterFailed(true); @@ -53,7 +60,7 @@ export const LoginPage: React.FC = () => { setIsSubmitting(false); } }, - [password, passwordConfirm, isSubmitting, navigate], + [password, passwordConfirm, isSubmitting, navigate, profileName, username], ); const onLogin = React.useCallback( @@ -63,10 +70,13 @@ export const LoginPage: React.FC = () => { setIsSubmitting(true); setLoginFailed(false); try { - const r = await login(password); + const r = await login(password, { + username, + deviceName: navigator.userAgent, + }); if (r?.success) { setAuthResult(r); - await mutate("/api/auth/verify", true, { revalidate: false }); + await mutate("/api/auth/verify"); navigate("/"); } else { setLoginFailed(true); @@ -77,7 +87,7 @@ export const LoginPage: React.FC = () => { setIsSubmitting(false); } }, - [password, isSubmitting, navigate], + [password, username, isSubmitting, navigate], ); React.useEffect(() => { @@ -96,6 +106,19 @@ export const LoginPage: React.FC = () => { {t("setupHelp")}

+ + setUsername(event.target.value)} + /> + + + setProfileName(event.target.value)} + /> + { 0) || + (password !== passwordConfirm && + passwordConfirm.length > 0) || registerFailed } - error={[ - registerFailed ? t("registerFailed") : t("mismatch"), - ]} + error={[registerFailed ? t("registerFailed") : t("mismatch")]} > { {t("welcomeBack")}
+ + setUsername(event.target.value)} + /> + [0]; mediaKey: string; + identityKey: string; } interface PendingPreferenceSave { preferences: PlaybackPreferences; version: number; + identityKey: string; } const preferenceAudioOptions: AudioTrackOption[] = [ @@ -224,6 +232,7 @@ export const PlayerPage: React.FC = () => { const [searchParams] = useSearchParams(); const navigate = useNavigate(); const { addToast } = useToast(); + const { canPlaybackWrite } = useAccess(); const file = searchParams.get("file") ?? undefined; const shouldAutoplay = searchParams.get("autoplay") === "1"; @@ -274,6 +283,9 @@ export const PlayerPage: React.FC = () => { const pendingPreferenceRef = React.useRef(null); const preferenceSaveRunningRef = React.useRef(false); const preferenceVersionRef = React.useRef(0); + const playerIdentityRef = React.useRef(getAuthIdentityKey()); + const canPlaybackWriteRef = React.useRef(canPlaybackWrite); + canPlaybackWriteRef.current = canPlaybackWrite; const activeMediaKey = `${animationId ?? ""}\u0000${file ?? ""}`; const activeMediaKeyRef = React.useRef(activeMediaKey); activeMediaKeyRef.current = activeMediaKey; @@ -295,6 +307,31 @@ export const PlayerPage: React.FC = () => { } }, [playbackContext]); + React.useEffect(() => { + if (canPlaybackWrite) return; + pendingProgressRef.current = null; + pendingPreferenceRef.current = null; + preferenceVersionRef.current += 1; + }, [canPlaybackWrite]); + + React.useEffect( + () => + subscribeToAuthChanges(({ auth, profileChanged }) => { + if (auth && !profileChanged) return; + // Keep the identity captured by this mounted player unchanged. Its + // teardown callbacks will therefore discard rather than persist the + // old profile's position/preferences with a replacement token. + pendingProgressRef.current = null; + pendingPreferenceRef.current = null; + preferenceVersionRef.current += 1; + contextRef.current = undefined; + preferencesRef.current = undefined; + lastSyncedTimeRef.current = -1; + artRef.current?.pause(); + }), + [], + ); + React.useEffect(() => { setExternalPlaybackUrl(null); setPlaybackUrl(null); @@ -527,9 +564,25 @@ export const PlayerPage: React.FC = () => { while (pendingProgressRef.current) { const pending = pendingProgressRef.current; pendingProgressRef.current = null; + if ( + !canSendProfileMutation( + pending.identityKey, + canPlaybackWriteRef.current, + ) + ) { + continue; + } try { const state = await savePlaybackProgress(pending.request); - if (activeMediaKeyRef.current !== pending.mediaKey) continue; + if ( + activeMediaKeyRef.current !== pending.mediaKey || + !canSendProfileMutation( + pending.identityKey, + canPlaybackWriteRef.current, + ) + ) { + continue; + } void mutateContext( (current) => (current ? { ...current, state } : current), false, @@ -541,7 +594,13 @@ export const PlayerPage: React.FC = () => { key.startsWith("/api/playback/states?")), ); } catch { - if (activeMediaKeyRef.current === pending.mediaKey) { + if ( + activeMediaKeyRef.current === pending.mediaKey && + canSendProfileMutation( + pending.identityKey, + canPlaybackWriteRef.current, + ) + ) { addToast({ title: i18n.t("player:progress.saveFailed"), color: "warning", @@ -556,6 +615,14 @@ export const PlayerPage: React.FC = () => { const persistCurrentProgress = React.useCallback( (force = false, keepalive = false) => { + const identityKey = playerIdentityRef.current; + if ( + !identityKey || + !canSendProfileMutation(identityKey, canPlaybackWriteRef.current) + ) { + pendingProgressRef.current = null; + return; + } const art = artRef.current; const context = contextRef.current; if (!art || !context) return; @@ -589,13 +656,15 @@ export const PlayerPage: React.FC = () => { // Teardown cannot wait behind an ordinary request. Drop any unsent // intermediate sample and dispatch the final position with keepalive. pendingProgressRef.current = null; - void savePlaybackProgress(request, true).catch(() => undefined); + if (canSendProfileMutation(identityKey, canPlaybackWriteRef.current)) { + void savePlaybackProgress(request, true).catch(() => undefined); + } return; } // Keep at most one unsent sample. Pause/seek events replace older timer // samples, while the single in-flight request preserves write order. - pendingProgressRef.current = { request, mediaKey }; + pendingProgressRef.current = { request, mediaKey, identityKey }; void flushProgressQueue(); }, [flushProgressQueue], @@ -837,9 +906,25 @@ export const PlayerPage: React.FC = () => { while (pendingPreferenceRef.current) { const pending = pendingPreferenceRef.current; pendingPreferenceRef.current = null; + if ( + !canSendProfileMutation( + pending.identityKey, + canPlaybackWriteRef.current, + ) + ) { + continue; + } try { const saved = await savePlaybackPreferences(pending.preferences); - if (preferenceVersionRef.current !== pending.version) continue; + if ( + preferenceVersionRef.current !== pending.version || + !canSendProfileMutation( + pending.identityKey, + canPlaybackWriteRef.current, + ) + ) { + continue; + } preferencesRef.current = saved; void mutateContext( (context) => @@ -847,7 +932,13 @@ export const PlayerPage: React.FC = () => { false, ); } catch { - if (preferenceVersionRef.current === pending.version) { + if ( + preferenceVersionRef.current === pending.version && + canSendProfileMutation( + pending.identityKey, + canPlaybackWriteRef.current, + ) + ) { addToast({ title: i18n.t("player:preferences.saveFailed"), color: "danger", @@ -864,13 +955,25 @@ export const PlayerPage: React.FC = () => { const updatePreferences = React.useCallback( (changes: Partial) => { + const identityKey = playerIdentityRef.current; + if ( + !identityKey || + !canSendProfileMutation(identityKey, canPlaybackWriteRef.current) + ) { + pendingPreferenceRef.current = null; + return; + } const current = preferencesRef.current; if (!current) return; const next: PlaybackPreferences = { ...current, ...changes }; preferencesRef.current = next; const version = preferenceVersionRef.current + 1; preferenceVersionRef.current = version; - pendingPreferenceRef.current = { preferences: next, version }; + pendingPreferenceRef.current = { + preferences: next, + version, + identityKey, + }; void mutateContext( (context) => (context ? { ...context, preferences: next } : context), false, @@ -921,7 +1024,13 @@ export const PlayerPage: React.FC = () => { ); const onToggleWatched = React.useCallback(async () => { - if (!playbackContext) return; + const identityKey = playerIdentityRef.current; + if ( + !playbackContext || + !canSendProfileMutation(identityKey, canPlaybackWriteRef.current) + ) { + return; + } const isWatched = !(playbackContext.state?.isWatched ?? false); setSavingWatched(true); try { @@ -930,6 +1039,9 @@ export const PlayerPage: React.FC = () => { path: playbackContext.media.path, isWatched, }); + if (!canSendProfileMutation(identityKey, canPlaybackWriteRef.current)) { + return; + } await mutateContext( (current) => (current ? { ...current, state } : current), false, @@ -945,7 +1057,9 @@ export const PlayerPage: React.FC = () => { color: "success", }); } catch { - addToast({ title: t("watched.failed"), color: "danger" }); + if (canSendProfileMutation(identityKey, canPlaybackWriteRef.current)) { + addToast({ title: t("watched.failed"), color: "danger" }); + } } finally { setSavingWatched(false); } @@ -1050,23 +1164,25 @@ export const PlayerPage: React.FC = () => {

- + {canPlaybackWrite ? ( + + ) : null} {playbackContext.next ? (
@@ -216,23 +236,32 @@ export const SeasonDiscovery: React.FC = () => {
{seasonData?.lastScrapedAt ? ( - {t("lastUpdated", { time: new Date(seasonData.lastScrapedAt).toLocaleString() })} + {t("lastUpdated", { + time: new Date(seasonData.lastScrapedAt).toLocaleString(), + })} ) : null} - + {isAdministrator ? ( + + ) : null}
{isLoading ? ( -
+
+ +
) : seasonData?.bangumis.length === 0 ? (

{t("empty")}

) : ( @@ -268,24 +297,26 @@ export const SeasonDiscovery: React.FC = () => { {bangumi.title}

- + {canContentWrite ? ( + + ) : null} + + {t("allSubgroups")} + + {canSubscribe ? ( + + ) : null}
); })()} @@ -410,24 +460,26 @@ const SubgroupList: React.FC<{ className="flex items-center justify-between rounded-md border border-border-light p-3" > {sg.name} - + {canSubscribe ? ( + + ) : null}
); })} diff --git a/SecondDimensionWatcherReDive.Client/src/settings/IWebDavToken.ts b/SecondDimensionWatcherReDive.Client/src/settings/IWebDavToken.ts index b42a2f9..96271b1 100644 --- a/SecondDimensionWatcherReDive.Client/src/settings/IWebDavToken.ts +++ b/SecondDimensionWatcherReDive.Client/src/settings/IWebDavToken.ts @@ -1,8 +1,13 @@ export interface IWebDavToken { id: string; + userId: string; username: string; description?: string; createdAt: string; + scope: string; + virtualRoot: string; + expiresAt?: string; + revokedAt?: string; } export interface ICreateWebDavTokenResponse { @@ -11,4 +16,8 @@ export interface ICreateWebDavTokenResponse { token: string; description?: string; createdAt: string; + userId: string; + scope: string; + virtualRoot: string; + expiresAt: string; } diff --git a/SecondDimensionWatcherReDive.Client/src/settings/utils.ts b/SecondDimensionWatcherReDive.Client/src/settings/utils.ts index 580a382..9b7aa5f 100644 --- a/SecondDimensionWatcherReDive.Client/src/settings/utils.ts +++ b/SecondDimensionWatcherReDive.Client/src/settings/utils.ts @@ -1,13 +1,22 @@ import fetcher from "../auth/httpClient"; import { ICreateWebDavTokenResponse } from "./IWebDavToken"; -export const createWebDavToken = (username?: string, description?: string) => +export const createWebDavToken = ( + username?: string, + description?: string, + virtualRoot = "/", + expiresAt?: string, + userId?: string, +) => fetcher("/api/webdav-tokens", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username || null, description: description || null, + virtualRoot, + expiresAt: expiresAt || null, + userId: userId || null, }), }); diff --git a/SecondDimensionWatcherReDive.Client/tsconfig.json b/SecondDimensionWatcherReDive.Client/tsconfig.json index 82c0627..c81e7e9 100644 --- a/SecondDimensionWatcherReDive.Client/tsconfig.json +++ b/SecondDimensionWatcherReDive.Client/tsconfig.json @@ -3,6 +3,7 @@ "target": "esnext", "module": "esnext", "lib": ["esnext", "dom"], + "types": ["node"], "allowJs": false, "jsx": "react-jsx", "noEmit": false, diff --git a/SecondDimensionWatcherReDive.Client/yarn.lock b/SecondDimensionWatcherReDive.Client/yarn.lock index dbd57a3..caa74f5 100644 --- a/SecondDimensionWatcherReDive.Client/yarn.lock +++ b/SecondDimensionWatcherReDive.Client/yarn.lock @@ -415,6 +415,7 @@ __metadata: "@tailwindcss/postcss": "npm:^4.3.3" "@tailwindcss/typography": "npm:^0.5.20" "@trivago/prettier-plugin-sort-imports": "npm:^6.0.2" + "@types/node": "npm:^26.4.0" "@types/react": "npm:^19.2.18" "@types/react-dom": "npm:^19.2.5" "@yarnpkg/sdks": "npm:^3.3.1" @@ -2659,7 +2660,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*": +"@types/node@npm:*, @types/node@npm:^26.4.0": version: 26.4.0 resolution: "@types/node@npm:26.4.0" dependencies: diff --git a/SecondDimensionWatcherReDive.Framework/Authorization/AccessControl.cs b/SecondDimensionWatcherReDive.Framework/Authorization/AccessControl.cs new file mode 100644 index 0000000..0da0e60 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/Authorization/AccessControl.cs @@ -0,0 +1,42 @@ +using System.Security.Claims; + +namespace SecondDimensionWatcherReDive.Framework.Authorization; + +public static class AccessPolicies +{ + public const string ContentWrite = nameof(ContentWrite); + public const string PlaybackWrite = nameof(PlaybackWrite); + public const string ChatWrite = nameof(ChatWrite); + public const string Administrator = nameof(Administrator); + public const string RecentAuthentication = nameof(RecentAuthentication); + public const string RecentAdministrator = nameof(RecentAdministrator); +} + +public static class IdentityClaimTypes +{ + public const string UserId = "userId"; + public const string ProfileId = "profileId"; + public const string SessionId = "sessionId"; + public const string AuthenticatedAt = "auth_time"; + public const string DeviceTokenId = "deviceTokenId"; + public const string DeviceScope = "deviceScope"; + public const string VirtualRoot = "virtualRoot"; +} + +public static class IdentityClaimsExtensions +{ + public static bool TryGetUserId(this ClaimsPrincipal principal, out Guid userId) => + TryGetGuid(principal, IdentityClaimTypes.UserId, out userId); + + public static bool TryGetProfileId(this ClaimsPrincipal principal, out Guid profileId) => + TryGetGuid(principal, IdentityClaimTypes.ProfileId, out profileId); + + public static bool TryGetSessionId(this ClaimsPrincipal principal, out Guid sessionId) => + TryGetGuid(principal, IdentityClaimTypes.SessionId, out sessionId); + + private static bool TryGetGuid( + ClaimsPrincipal principal, + string claimType, + out Guid value) => + Guid.TryParse(principal.FindFirst(claimType)?.Value, out value); +} diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IChatRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IChatRepository.cs index 8c94753..be7e26d 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/IChatRepository.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IChatRepository.cs @@ -2,13 +2,42 @@ namespace SecondDimensionWatcherReDive.Framework.DataRepository; public interface IChatRepository { - Task> GetConversationsAsync(CancellationToken cancellationToken); - Task GetConversationWithMessagesAsync(Guid id, CancellationToken cancellationToken); - Task CreateConversationAsync(string? title, CancellationToken cancellationToken); - Task DeleteConversationAsync(Guid id, CancellationToken cancellationToken); - Task UpdateConversationTitleAsync(Guid id, string title, CancellationToken cancellationToken); - Task AddMessageAsync(Guid conversationId, ChatMessageRecord message, CancellationToken cancellationToken); - Task AddMessagesAsync(Guid conversationId, IEnumerable messages, CancellationToken cancellationToken); - Task> GetMessagesAsync(Guid conversationId, CancellationToken cancellationToken); - Task GetMessageCountAsync(Guid conversationId, CancellationToken cancellationToken); + Task> GetConversationsAsync( + Guid profileId, + CancellationToken cancellationToken); + Task GetConversationWithMessagesAsync( + Guid id, + Guid profileId, + CancellationToken cancellationToken); + Task CreateConversationAsync( + Guid profileId, + string? title, + CancellationToken cancellationToken); + Task DeleteConversationAsync( + Guid id, + Guid profileId, + CancellationToken cancellationToken); + Task UpdateConversationTitleAsync( + Guid id, + Guid profileId, + string title, + CancellationToken cancellationToken); + Task AddMessageAsync( + Guid conversationId, + Guid profileId, + ChatMessageRecord message, + CancellationToken cancellationToken); + Task AddMessagesAsync( + Guid conversationId, + Guid profileId, + IEnumerable messages, + CancellationToken cancellationToken); + Task> GetMessagesAsync( + Guid conversationId, + Guid profileId, + CancellationToken cancellationToken); + Task GetMessageCountAsync( + Guid conversationId, + Guid profileId, + CancellationToken cancellationToken); } diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IIdentityRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IIdentityRepository.cs new file mode 100644 index 0000000..2944e4e --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IIdentityRepository.cs @@ -0,0 +1,80 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public interface IIdentityRepository +{ + Task AnyUsersAsync(CancellationToken cancellationToken); + + Task FindUserByIdAsync(Guid id, CancellationToken cancellationToken); + + Task FindUserByUsernameAsync( + string username, + CancellationToken cancellationToken); + + Task FindProfileAsync(Guid id, CancellationToken cancellationToken); + + Task> GetProfilesAsync( + Guid userId, + CancellationToken cancellationToken); + + Task CreateUserWithProfileAsync( + UserAccount user, + UserProfile profile, + CancellationToken cancellationToken); + + Task SetPasswordHashAsync( + Guid userId, + string passwordHash, + DateTimeOffset now, + CancellationToken cancellationToken); + + Task AddProfileAsync( + UserProfile profile, + CancellationToken cancellationToken); + + Task UpdateProfileAsync( + Guid profileId, + Guid userId, + string name, + string? avatar, + string? pinHash, + bool replacePin, + DateTimeOffset now, + CancellationToken cancellationToken); + + Task> GetUsersAsync( + CancellationToken cancellationToken); + + Task UpdateUserAccessAsync( + Guid userId, + UserRole role, + bool isDisabled, + DateTimeOffset now, + CancellationToken cancellationToken); + + Task AddSessionAsync(UserSession session, CancellationToken cancellationToken); + + Task GetAuthenticatedSessionAsync( + Guid sessionId, + DateTimeOffset now, + CancellationToken cancellationToken); + + Task TryRotateSessionAsync( + Guid sessionId, + string expectedRefreshTokenHash, + string newRefreshTokenHash, + Guid activeProfileId, + DateTimeOffset? authenticatedAt, + DateTimeOffset now, + DateTimeOffset expiresAt, + CancellationToken cancellationToken); + + Task> GetSessionsAsync( + Guid? userId, + CancellationToken cancellationToken); + + Task RevokeSessionAsync( + Guid sessionId, + Guid? requiredUserId, + DateTimeOffset revokedAt, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IWebDavTokenRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IWebDavTokenRepository.cs index 389f188..c303e20 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/IWebDavTokenRepository.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IWebDavTokenRepository.cs @@ -10,5 +10,8 @@ public interface IWebDavTokenRepository Task AddAsync(WebDavToken token, CancellationToken cancellationToken); - Task RemoveByIdAsync(Guid id, CancellationToken cancellationToken); + Task RevokeByIdAsync( + Guid id, + DateTimeOffset revokedAt, + CancellationToken cancellationToken); } diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/Identity.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/Identity.cs new file mode 100644 index 0000000..fb753b3 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/Identity.cs @@ -0,0 +1,71 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public enum UserRole +{ + Admin, + Member, + Viewer +} + +public enum UpdateUserAccessResult +{ + Updated, + NotFound, + LastAdministrator +} + +public sealed class IdentityConflictException(string message, Exception? innerException = null) + : Exception(message, innerException); + +public static class IdentityDefaults +{ + public static readonly Guid UserId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + public static readonly Guid ProfileId = Guid.Empty; + public const string Username = "admin"; + public const string ProfileName = "Home"; +} + +public sealed record UserAccount( + Guid Id, + string Username, + string? PasswordHash, + UserRole Role, + bool IsDisabled, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record UserProfile( + Guid Id, + Guid UserId, + string Name, + string? Avatar, + string? PinHash, + bool IsDefault, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record UserSession( + Guid Id, + Guid UserId, + Guid ActiveProfileId, + string RefreshTokenHash, + string? DeviceName, + DateTimeOffset AuthenticatedAt, + DateTimeOffset CreatedAt, + DateTimeOffset LastSeenAt, + DateTimeOffset ExpiresAt, + DateTimeOffset? RevokedAt); + +public sealed record AuthenticatedSession( + UserAccount User, + UserProfile Profile, + UserSession Session); + +public sealed record UserAccountWithProfiles( + UserAccount User, + IReadOnlyList Profiles); + +public sealed record UserSessionSummary( + UserSession Session, + string Username, + string ProfileName); diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/WebDavToken.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/WebDavToken.cs index beede59..9ae0a00 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/WebDavToken.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/WebDavToken.cs @@ -2,7 +2,12 @@ namespace SecondDimensionWatcherReDive.Framework.DataRepository; public sealed record WebDavToken( Guid Id, + Guid UserId, string Username, string TokenHash, string? Description, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt, + string Scope, + string VirtualRoot, + DateTimeOffset? ExpiresAt, + DateTimeOffset? RevokedAt); diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Auth/RoleAuthorizationTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/Auth/RoleAuthorizationTests.cs new file mode 100644 index 0000000..83652d5 --- /dev/null +++ b/SecondDimensionWatcherReDive.IntegrationTest/Auth/RoleAuthorizationTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Http.Json; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.IntegrationTest.TestData; + +namespace SecondDimensionWatcherReDive.IntegrationTest.Auth; + +[TestClass] +public sealed class RoleAuthorizationTests +{ + [TestMethod] + public async Task Viewer_CanReadFiles_ButCannotWriteContentPlaybackOrTasks() + { + using var factory = new WebDavWebApplicationFactory(role: UserRole.Viewer); + factory.ResetState(); + factory.Mappings.Add(WebDavMappingFixtures.NewMapping( + "/shows/episode.mkv", "/disk/episode.mkv")); + using var client = factory.CreateJwtClient(); + + using var read = await client.GetAsync("/api/vfs/stat?path=/shows/episode.mkv"); + using var addFeed = await client.PostAsJsonAsync("/api/feed", new + { + url = "https://example.test/feed.xml", + name = "test" + }); + using var playback = await client.PutAsJsonAsync("/api/playback/preferences", new + { + autoPlayNext = true + }); + using var task = await client.PostAsync("/api/tasks/SyncFeed/run", null); + + Assert.AreEqual(HttpStatusCode.OK, read.StatusCode); + Assert.AreEqual(HttpStatusCode.Forbidden, addFeed.StatusCode); + Assert.AreEqual(HttpStatusCode.Forbidden, playback.StatusCode); + Assert.AreEqual(HttpStatusCode.Forbidden, task.StatusCode); + } + + [TestMethod] + public async Task Member_CannotRunAdministratorTask() + { + using var factory = new WebDavWebApplicationFactory(role: UserRole.Member); + using var client = factory.CreateJwtClient(); + + using var response = await client.PostAsync("/api/tasks/SyncFeed/run", null); + + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + } + + [TestMethod] + public async Task Member_CannotReadOrWriteSettingsManageUsersOrDeleteDownloadedFiles() + { + using var factory = new WebDavWebApplicationFactory(role: UserRole.Member); + using var client = factory.CreateJwtClient(); + var animationId = Guid.NewGuid(); + + using var readSettings = await client.GetAsync("/api/settings"); + using var writeSettings = await client.PatchAsJsonAsync("/api/settings", new { }); + using var manageUsers = await client.GetAsync("/api/accounts/users"); + using var deleteFiles = await client.DeleteAsync( + $"/api/animationinfo/cancel/{animationId}?removeFile=true"); + + Assert.AreEqual(HttpStatusCode.Forbidden, readSettings.StatusCode); + Assert.AreEqual(HttpStatusCode.Forbidden, writeSettings.StatusCode); + Assert.AreEqual(HttpStatusCode.Forbidden, manageUsers.StatusCode); + Assert.AreEqual(HttpStatusCode.Forbidden, deleteFiles.StatusCode); + } + + [TestMethod] + public async Task Viewer_CannotStartOrCancelDownloadsOrWriteChat() + { + using var factory = new WebDavWebApplicationFactory(role: UserRole.Viewer); + using var client = factory.CreateJwtClient(); + var animationId = Guid.NewGuid(); + var conversationId = Guid.NewGuid(); + + using var start = await client.PostAsync( + $"/api/animationinfo/download/{animationId}", null); + using var cancel = await client.DeleteAsync( + $"/api/animationinfo/cancel/{animationId}"); + using var createChat = await client.PostAsJsonAsync( + "/api/chat/conversations", new { title = "blocked" }); + using var sendChat = await client.PostAsJsonAsync( + $"/api/chat/conversations/{conversationId}/messages", + new { content = "blocked" }); + + Assert.AreEqual(HttpStatusCode.Forbidden, start.StatusCode); + Assert.AreEqual(HttpStatusCode.Forbidden, cancel.StatusCode); + Assert.AreEqual(HttpStatusCode.Forbidden, createChat.StatusCode); + Assert.AreEqual(HttpStatusCode.Forbidden, sendChat.StatusCode); + } + + [TestMethod] + public async Task MissingSessionToken_IsUnauthorized() + { + using var factory = new WebDavWebApplicationFactory(); + using var client = factory.CreateUnauthenticatedClient(); + + using var response = await client.GetAsync("/api/feed"); + + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [TestMethod] + public async Task RevokedLoginSession_InvalidatesExistingAccessTokenImmediately() + { + using var factory = new WebDavWebApplicationFactory(); + factory.Mappings.Add(WebDavMappingFixtures.NewMapping( + "/shows/episode.mkv", "/disk/episode.mkv")); + using var client = factory.CreateJwtClient(); + using var before = await client.GetAsync("/api/vfs/stat?path=/shows/episode.mkv"); + Assert.AreEqual(HttpStatusCode.OK, before.StatusCode); + + factory.RevokeLoginSession(); + using var after = await client.GetAsync("/api/vfs/stat?path=/shows/episode.mkv"); + + Assert.AreEqual(HttpStatusCode.Unauthorized, after.StatusCode); + } +} diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeWebDavTokenRepository.cs b/SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeWebDavTokenRepository.cs index 3d9480a..409a46f 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeWebDavTokenRepository.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeWebDavTokenRepository.cs @@ -4,11 +4,34 @@ namespace SecondDimensionWatcherReDive.IntegrationTest.Helpers; internal sealed class FakeWebDavTokenRepository : IWebDavTokenRepository { - private readonly WebDavToken _seeded; + private WebDavToken _seeded; - public FakeWebDavTokenRepository(string username, string tokenHash) + public Guid TokenId => _seeded.Id; + + public void Expire() => _seeded = _seeded with + { + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1) + }; + + public void SetScope(string scope) => _seeded = _seeded with { Scope = scope }; + + public FakeWebDavTokenRepository( + Guid userId, + string username, + string tokenHash, + string virtualRoot = "/") { - _seeded = new WebDavToken(Guid.NewGuid(), username, tokenHash, "integration-test", DateTimeOffset.UtcNow); + _seeded = new WebDavToken( + Guid.NewGuid(), + userId, + username, + tokenHash, + "integration-test", + DateTimeOffset.UtcNow, + "read", + virtualRoot, + DateTimeOffset.UtcNow.AddDays(1), + null); } public Task> GetAllOrderedAsync(CancellationToken cancellationToken) @@ -23,6 +46,13 @@ public Task ExistsByUsernameAsync(string username, CancellationToken cance public Task AddAsync(WebDavToken token, CancellationToken cancellationToken) => throw new NotSupportedException(); - public Task RemoveByIdAsync(Guid id, CancellationToken cancellationToken) - => throw new NotSupportedException(); + public Task RevokeByIdAsync( + Guid id, + DateTimeOffset revokedAt, + CancellationToken cancellationToken) + { + if (_seeded.Id != id) return Task.FromResult(false); + _seeded = _seeded with { RevokedAt = revokedAt }; + return Task.FromResult(true); + } } diff --git a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/HouseholdMigrationPostgreSqlTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/HouseholdMigrationPostgreSqlTests.cs new file mode 100644 index 0000000..8d1975f --- /dev/null +++ b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/HouseholdMigrationPostgreSqlTests.cs @@ -0,0 +1,188 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Repositories; +using Testcontainers.PostgreSql; + +namespace SecondDimensionWatcherReDive.IntegrationTest.PostgreSql; + +[TestClass] +public sealed class HouseholdMigrationPostgreSqlTests +{ + private static readonly PostgreSqlContainer Database = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("sdw_identity_tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + private static HouseholdMigrationPostgreSqlTestFixture Fixture = null!; + + [ClassInitialize] + public static async Task InitializeAsync(TestContext _) + { + await Database.StartAsync(); + Fixture = new HouseholdMigrationPostgreSqlTestFixture(Database.GetConnectionString()); + } + + [ClassCleanup] + public static async Task CleanupAsync() => await Database.DisposeAsync(); + + [TestInitialize] + public async Task ResetAsync() => await Fixture.RecreateAsync(CancellationToken.None); + + [TestMethod] + public async Task LegacyHistory_IsAssignedToDefaultProfile_BeforeForeignKeysAreAdded() + { + await Fixture.SeedLegacyAndUpgradeAsync(CancellationToken.None); + + var snapshot = await Fixture.InspectAsync(CancellationToken.None); + Assert.AreEqual(1, snapshot.UserCount); + Assert.AreEqual(IdentityDefaults.UserId, snapshot.UserId); + Assert.AreEqual("admin", snapshot.Username); + Assert.AreEqual(UserRole.Admin, snapshot.Role); + Assert.AreEqual(IdentityDefaults.ProfileId, snapshot.ProfileId); + Assert.AreEqual("Home", snapshot.ProfileName); + Assert.AreEqual(IdentityDefaults.ProfileId, snapshot.ProgressProfileId); + Assert.AreEqual(123d, snapshot.PositionSeconds); + Assert.AreEqual(IdentityDefaults.ProfileId, snapshot.PreferenceProfileId); + Assert.AreEqual("zh-Hans", snapshot.SubtitleLanguage); + Assert.AreEqual(IdentityDefaults.ProfileId, snapshot.ConversationProfileId); + Assert.AreEqual("legacy chat", snapshot.ConversationTitle); + Assert.AreEqual(IdentityDefaults.UserId, snapshot.DeviceUserId); + Assert.AreEqual("read", snapshot.DeviceScope); + Assert.AreEqual("/", snapshot.DeviceRoot); + Assert.IsNull(snapshot.DeviceExpiresAt); + Assert.IsNull(snapshot.DeviceRevokedAt); + + var down = await Fixture.MigrateDownAsync(CancellationToken.None); + Assert.AreEqual(1, down.PlaybackCount); + Assert.AreEqual(1, down.PreferenceCount); + Assert.AreEqual(1, down.ConversationCount); + Assert.AreEqual(1, down.DeviceTokenCount); + Assert.IsFalse(down.UsersTableExists); + Assert.IsFalse(down.ProfilesTableExists); + + // Re-upgrade proves Down left the legacy rows in a valid, recoverable state. + await Fixture.UpgradeAsync(CancellationToken.None); + var reupgraded = await Fixture.InspectAsync(CancellationToken.None); + Assert.AreEqual(123d, reupgraded.PositionSeconds); + } + + [TestMethod] + public async Task CleanMigration_LeavesRegistrationOpen_AndCanDowngrade() + { + await Fixture.MigrateCleanDatabaseAsync(CancellationToken.None); + Assert.AreEqual(0, await Fixture.GetUserCountAsync(CancellationToken.None)); + + var registration = await Fixture.RegisterAndCreateSessionAsync(CancellationToken.None); + Assert.AreEqual(IdentityDefaults.ProfileId, registration.PersistedProfileId); + Assert.AreEqual(IdentityDefaults.ProfileId, registration.IssuedProfileId); + Assert.IsTrue(registration.SessionIsActive); + Assert.AreEqual(2, registration.UserCount); + + var registeredDown = await Fixture.AttemptUnsafeDowngradeAsync( + CancellationToken.None); + Assert.IsTrue(registeredDown.Rejected); + Assert.IsTrue(registeredDown.CurrentMigrationStillApplied); + Assert.AreEqual(2, registeredDown.UserCount); + + await Fixture.RecreateAsync(CancellationToken.None); + await Fixture.MigrateCleanDatabaseAsync(CancellationToken.None); + var down = await Fixture.MigrateDownAsync(CancellationToken.None); + Assert.AreEqual(0, down.PlaybackCount); + Assert.AreEqual(0, down.PreferenceCount); + Assert.AreEqual(0, down.ConversationCount); + Assert.AreEqual(0, down.DeviceTokenCount); + Assert.IsFalse(down.UsersTableExists); + Assert.IsFalse(down.ProfilesTableExists); + } + + [TestMethod] + public async Task ConcurrentAdminDemotions_CannotRemoveLastEnabledAdministrator() + { + await Fixture.MigrateCleanDatabaseAsync(CancellationToken.None); + + var result = await Fixture.DemoteTwoAdminsConcurrentlyAsync(CancellationToken.None); + + Assert.AreEqual(1, result.Results.Count(item => item == UpdateUserAccessResult.Updated)); + Assert.AreEqual(1, result.Results.Count(item => item == UpdateUserAccessResult.LastAdministrator)); + Assert.AreEqual(1, result.EnabledAdministratorCount); + } + + [TestMethod] + public async Task Profiles_HaveIndependentPlaybackPreferencesAndConversations() + { + await Fixture.MigrateCleanDatabaseAsync(CancellationToken.None); + + var result = await Fixture.ExerciseProfileIsolationAsync(CancellationToken.None); + + Assert.AreEqual(10d, result.FirstPosition); + Assert.AreEqual(70d, result.SecondPosition); + Assert.AreEqual("zh-Hans", result.FirstSubtitleLanguage); + Assert.AreEqual("en", result.SecondSubtitleLanguage); + Assert.AreEqual(1, result.FirstConversationCount); + Assert.AreEqual(1, result.SecondConversationCount); + Assert.IsTrue(result.CrossProfileConversationHidden); + Assert.AreEqual(10d, result.FirstContinuePosition); + Assert.AreEqual(70d, result.SecondContinuePosition); + } + + [TestMethod] + public async Task ProfileSwitchLogoutRevokeAndRefreshRotation_InvalidateOldCredentials() + { + await Fixture.MigrateCleanDatabaseAsync(CancellationToken.None); + + var result = await Fixture.ExerciseSessionLifecycleAsync(CancellationToken.None); + + Assert.IsTrue(result.WrongPinRejected); + Assert.IsTrue(result.CorrectPinRotated); + Assert.IsTrue(result.OldAccessRejected); + Assert.IsTrue(result.NewProfileClaimIsActive); + Assert.IsTrue(result.OldRefreshReplayRejected); + Assert.IsTrue(result.LogoutRejectedAccess); + Assert.IsTrue(result.LogoutRejectedRefresh); + Assert.IsTrue(result.AdministratorRevokeRejectedAccess); + Assert.IsTrue(result.AdministratorRevokeRejectedRefresh); + Assert.AreEqual(1, result.ConcurrentRefreshSuccessCount); + } + + [TestMethod] + public async Task Downgrade_WithMultipleProfilesAndHistory_IsRejectedWithoutMutation() + { + await Fixture.MigrateCleanDatabaseAsync(CancellationToken.None); + await Fixture.ExerciseProfileIsolationAsync(CancellationToken.None); + + var result = await Fixture.AttemptUnsafeDowngradeAsync(CancellationToken.None); + + Assert.IsTrue(result.Rejected); + Assert.IsTrue(result.CurrentMigrationStillApplied); + Assert.AreEqual(1, result.UserCount); + Assert.AreEqual(2, result.ProfileCount); + Assert.AreEqual(2, result.ProgressCount); + Assert.AreEqual(2, result.PreferenceCount); + } + + [TestMethod] + public async Task Downgrade_WithScopedExpiringDeviceToken_IsRejectedWithoutWideningIt() + { + await Fixture.MigrateCleanDatabaseAsync(CancellationToken.None); + await Fixture.SeedUnsafeScopedDeviceTokenAsync(CancellationToken.None); + + var result = await Fixture.AttemptUnsafeDowngradeAsync(CancellationToken.None); + + Assert.IsTrue(result.Rejected); + Assert.IsTrue(result.CurrentMigrationStillApplied); + Assert.AreEqual(1, result.DeviceTokenCount); + } + + [TestMethod] + public async Task ConcurrentFirstRegistration_ReturnsConflictInsteadOfServerError() + { + await Fixture.MigrateCleanDatabaseAsync(CancellationToken.None); + + var result = await Fixture.RegisterConcurrentlyAsync(CancellationToken.None); + + Assert.AreEqual(1, result.SuccessCount); + Assert.AreEqual(1, result.ConflictCount); + Assert.AreEqual(1, result.UserCount); + Assert.AreEqual(1, result.SessionCount); + } +} diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Repositories/HouseholdMigrationPostgreSqlTestFixture.cs b/SecondDimensionWatcherReDive.IntegrationTest/Repositories/HouseholdMigrationPostgreSqlTestFixture.cs new file mode 100644 index 0000000..66948d2 --- /dev/null +++ b/SecondDimensionWatcherReDive.IntegrationTest/Repositories/HouseholdMigrationPostgreSqlTestFixture.cs @@ -0,0 +1,786 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.Tokens; +using Moq; +using Npgsql; +using SecondDimensionWatcherReDive.Auth; +using SecondDimensionWatcherReDive.Controllers; +using SecondDimensionWatcherReDive.Framework.Authorization; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Repositories; + +internal sealed record HouseholdMigrationSnapshot( + int UserCount, + Guid? UserId, + string? Username, + UserRole? Role, + Guid? ProfileId, + string? ProfileName, + Guid? ProgressProfileId, + double? PositionSeconds, + Guid? PreferenceProfileId, + string? SubtitleLanguage, + Guid? ConversationProfileId, + string? ConversationTitle, + Guid? DeviceUserId, + string? DeviceScope, + string? DeviceRoot, + DateTimeOffset? DeviceExpiresAt, + DateTimeOffset? DeviceRevokedAt); + +internal sealed record HouseholdMigrationDownSnapshot( + int PlaybackCount, + int PreferenceCount, + int ConversationCount, + int DeviceTokenCount, + bool UsersTableExists, + bool ProfilesTableExists); + +internal sealed record CleanRegistrationSnapshot( + Guid PersistedProfileId, + Guid IssuedProfileId, + bool SessionIsActive, + int UserCount); + +internal sealed record ConcurrentAdminUpdateSnapshot( + IReadOnlyList Results, + int EnabledAdministratorCount); + +internal sealed record ProfileIsolationSnapshot( + double? FirstPosition, + double? SecondPosition, + string? FirstSubtitleLanguage, + string? SecondSubtitleLanguage, + int FirstConversationCount, + int SecondConversationCount, + bool CrossProfileConversationHidden, + double? FirstContinuePosition, + double? SecondContinuePosition); + +internal sealed record SessionLifecycleSnapshot( + bool WrongPinRejected, + bool CorrectPinRotated, + bool OldAccessRejected, + bool NewProfileClaimIsActive, + bool OldRefreshReplayRejected, + bool LogoutRejectedAccess, + bool LogoutRejectedRefresh, + bool AdministratorRevokeRejectedAccess, + bool AdministratorRevokeRejectedRefresh, + int ConcurrentRefreshSuccessCount); + +internal sealed record ConcurrentRegistrationSnapshot( + int SuccessCount, + int ConflictCount, + int UserCount, + int SessionCount); + +internal sealed record DowngradeSafetySnapshot( + bool Rejected, + bool CurrentMigrationStillApplied, + int UserCount, + int ProfileCount, + int ProgressCount, + int PreferenceCount, + int DeviceTokenCount); + +/// +/// PostgreSQL-only migration fixture. It lives in the integration-test repository boundary so EF entities and +/// ApplicationContext never escape the permitted data-access boundary. +/// +internal sealed class HouseholdMigrationPostgreSqlTestFixture(string connectionString) +{ + internal const string PreviousMigration = "20260828164158_AddApplicationSettings"; + + private readonly DbContextOptions _contextOptions = + new DbContextOptionsBuilder() + .UseNpgsql(connectionString, options => options.EnableRetryOnFailure()) + .Options; + + public async Task RecreateAsync(CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + await context.Database.EnsureDeletedAsync(cancellationToken); + } + + public async Task SeedLegacyAndUpgradeAsync(CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + await context.Database.MigrateAsync(PreviousMigration, cancellationToken); + + var now = DateTimeOffset.UtcNow; + var animationInfoId = Guid.Parse("41000000-0000-0000-0000-000000000001"); + context.AnimationInfo.Add(new Models.AnimationInfo + { + Id = animationInfoId, + Title = "legacy episode", + Description = string.Empty, + PublishTime = now, + DownloadUrl = string.Empty, + DownloadType = string.Empty, + CachedDownloadData = [], + AdditionalDownloadInfo = string.Empty, + IsDownloadFinished = true, + FileStore = "local", + StorePath = "/legacy" + }); + await context.SaveChangesAsync(cancellationToken); + + var progressId = Guid.Parse("42000000-0000-0000-0000-000000000001"); + var conversationId = Guid.Parse("43000000-0000-0000-0000-000000000001"); + var tokenId = Guid.Parse("44000000-0000-0000-0000-000000000001"); + await context.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO "PlaybackProgresses" + ("Id", "UserId", "AnimationInfoId", "VirtualPath", "PositionSeconds", + "DurationSeconds", "IsWatched", "UpdatedAt", "WatchedAt") + VALUES + ({progressId}, {Guid.Empty}, {animationInfoId}, {'/' + "legacy/episode.mkv"}, + {123d}, {1500d}, {false}, {now}, {null}); + """, + cancellationToken); + await context.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO "PlaybackPreferences" + ("UserId", "SubtitleLanguage", "SubtitleTrackLabel", "AudioLanguage", + "AudioTrackLabel", "AutoPlayNext", "UpdatedAt") + VALUES ({Guid.Empty}, {"zh-Hans"}, {null}, {"ja"}, {null}, {true}, {now}); + """, + cancellationToken); + await context.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO "ChatConversations" ("Id", "Title", "CreatedAt", "UpdatedAt") + VALUES ({conversationId}, {"legacy chat"}, {now}, {now}); + """, + cancellationToken); + await context.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO "WebDavTokens" + ("Id", "Username", "TokenHash", "Description", "CreatedAt") + VALUES ({tokenId}, {"legacy-device"}, {"legacy-hash"}, {"old client"}, {now}); + """, + cancellationToken); + + await context.Database.MigrateAsync(cancellationToken); + } + + public async Task InspectAsync( + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var user = await context.Users.AsNoTracking().SingleOrDefaultAsync(cancellationToken); + var profile = await context.Profiles.AsNoTracking().SingleOrDefaultAsync(cancellationToken); + var progress = await context.PlaybackProgresses.AsNoTracking() + .SingleOrDefaultAsync(cancellationToken); + var preference = await context.PlaybackPreferences.AsNoTracking() + .SingleOrDefaultAsync(cancellationToken); + var conversation = await context.ChatConversations.AsNoTracking() + .SingleOrDefaultAsync(cancellationToken); + var token = await context.WebDavTokens.AsNoTracking() + .SingleOrDefaultAsync(cancellationToken); + return new HouseholdMigrationSnapshot( + await context.Users.CountAsync(cancellationToken), + user?.Id, + user?.Username, + user?.Role, + profile?.Id, + profile?.Name, + progress?.UserId, + progress?.PositionSeconds, + preference?.UserId, + preference?.SubtitleLanguage, + conversation?.ProfileId, + conversation?.Title, + token?.UserId, + token?.Scope, + token?.VirtualRoot, + token?.ExpiresAt, + token?.RevokedAt); + } + + public async Task MigrateCleanDatabaseAsync(CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + await context.Database.MigrateAsync(cancellationToken); + } + + public Task UpgradeAsync(CancellationToken cancellationToken) => + MigrateCleanDatabaseAsync(cancellationToken); + + public async Task GetUserCountAsync(CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await context.Users.CountAsync(cancellationToken); + } + + public async Task RegisterAndCreateSessionAsync( + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var repository = new IdentityRepository(context); + var now = DateTimeOffset.UtcNow; + var user = new UserAccount( + IdentityDefaults.UserId, + IdentityDefaults.Username, + BCrypt.Net.BCrypt.HashPassword("integration-password"), + UserRole.Admin, + false, + now, + now); + var profile = new UserProfile( + IdentityDefaults.ProfileId, + user.Id, + IdentityDefaults.ProfileName, + null, + null, + true, + now, + now); + await repository.CreateUserWithProfileAsync(user, profile, cancellationToken); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["JwtSecret"] = "postgres-integration-secret-long-enough-123456" + }) + .Build(); + var issuer = new SessionTokenIssuer(configuration, repository); + var issued = await issuer.CreateSessionAsync( + user, profile, "integration", cancellationToken); + var persistedProfileId = await context.Profiles + .Select(candidate => candidate.Id) + .SingleAsync(cancellationToken); + var active = await repository.GetAuthenticatedSessionAsync( + issued.SessionId, DateTimeOffset.UtcNow, cancellationToken); + var secondUser = new UserAccount( + Guid.NewGuid(), + "family-member", + BCrypt.Net.BCrypt.HashPassword("member-password"), + UserRole.Member, + false, + now, + now); + var secondProfile = new UserProfile( + Guid.NewGuid(), + secondUser.Id, + "Member Home", + null, + null, + true, + now, + now); + await repository.CreateUserWithProfileAsync( + secondUser, secondProfile, cancellationToken); + return new CleanRegistrationSnapshot( + persistedProfileId, + issued.ProfileId, + active is not null, + await context.Users.CountAsync(cancellationToken)); + } + + public async Task DemoteTwoAdminsConcurrentlyAsync( + CancellationToken cancellationToken) + { + var firstUserId = Guid.Parse("51000000-0000-0000-0000-000000000001"); + var secondUserId = Guid.Parse("51000000-0000-0000-0000-000000000002"); + await using (var seedContext = new Models.ApplicationContext(_contextOptions)) + { + var now = DateTimeOffset.UtcNow; + seedContext.Users.AddRange( + UserEntity(firstUserId, "first-admin", now), + UserEntity(secondUserId, "second-admin", now)); + seedContext.Profiles.AddRange( + ProfileEntity(Guid.Parse("52000000-0000-0000-0000-000000000001"), firstUserId, now), + ProfileEntity(Guid.Parse("52000000-0000-0000-0000-000000000002"), secondUserId, now)); + await seedContext.SaveChangesAsync(cancellationToken); + } + + async Task DemoteAsync(Guid id) + { + await using var updateContext = new Models.ApplicationContext(_contextOptions); + var repository = new IdentityRepository(updateContext); + return await repository.UpdateUserAccessAsync( + id, + UserRole.Member, + false, + DateTimeOffset.UtcNow, + cancellationToken); + } + + var results = await Task.WhenAll( + DemoteAsync(firstUserId), + DemoteAsync(secondUserId)); + await using var inspectContext = new Models.ApplicationContext(_contextOptions); + var enabledAdmins = await inspectContext.Users.CountAsync( + user => user.Role == UserRole.Admin && !user.IsDisabled, + cancellationToken); + return new ConcurrentAdminUpdateSnapshot(results, enabledAdmins); + } + + public async Task ExerciseSessionLifecycleAsync( + CancellationToken cancellationToken) + { + var configuration = CreateJwtConfiguration(); + var validationParameters = CreateTokenValidationParameters(configuration); + var now = DateTimeOffset.UtcNow; + var user = new UserAccount( + Guid.Parse("61000000-0000-0000-0000-000000000001"), + "session-user", + BCrypt.Net.BCrypt.HashPassword("session-password"), + UserRole.Admin, + false, + now, + now); + var firstProfile = new UserProfile( + Guid.Parse("62000000-0000-0000-0000-000000000001"), + user.Id, + "First", + null, + null, + true, + now, + now); + var secondProfile = new UserProfile( + Guid.Parse("62000000-0000-0000-0000-000000000002"), + user.Id, + "Second", + null, + BCrypt.Net.BCrypt.HashPassword("2468"), + false, + now, + now); + + await using var context = new Models.ApplicationContext(_contextOptions); + var repository = new IdentityRepository(context); + await repository.CreateUserWithProfileAsync(user, firstProfile, cancellationToken); + await repository.AddProfileAsync(secondProfile, cancellationToken); + var issuer = new SessionTokenIssuer(configuration, repository); + var initial = await issuer.CreateSessionAsync( + user, firstProfile, "profile-switch-test", cancellationToken); + var oldPrincipal = ValidateToken(initial.AccessToken, validationParameters); + var authorization = new Mock(); + var accounts = CreateAccountsController( + repository, issuer, authorization.Object, oldPrincipal); + + var wrongPin = await accounts.SwitchProfile( + new Controllers.External.SwitchProfileRequest( + secondProfile.Id, "0000", initial.RefreshToken), + cancellationToken); + var afterWrongPin = await repository.GetAuthenticatedSessionAsync( + initial.SessionId, DateTimeOffset.UtcNow, cancellationToken); + var wrongPinRejected = wrongPin is UnauthorizedResult + && afterWrongPin?.Profile.Id == firstProfile.Id; + + var correctPin = await accounts.SwitchProfile( + new Controllers.External.SwitchProfileRequest( + secondProfile.Id, "2468", initial.RefreshToken), + cancellationToken); + var rotated = (correctPin as OkObjectResult)?.Value + as Controllers.External.LoginResult; + if (rotated?.Token is null || rotated.RefreshToken is null) + throw new InvalidOperationException("Profile switch did not issue tokens."); + var newPrincipal = ValidateToken(rotated.Token, validationParameters); + var correctPinRotated = rotated.ProfileId == secondProfile.Id + && rotated.RefreshToken != initial.RefreshToken; + var oldAccessRejected = !await IsPrincipalCurrentAsync( + oldPrincipal, repository, cancellationToken); + var newProfileClaimIsActive = newPrincipal.TryGetProfileId(out var newProfileId) + && newProfileId == secondProfile.Id + && await IsPrincipalCurrentAsync( + newPrincipal, repository, cancellationToken); + + var auth = CreateAuthController( + configuration, validationParameters, repository, issuer); + var oldReplay = await auth.Refresh( + new Controllers.External.AuthRequest( + initial.AccessToken, initial.RefreshToken), + cancellationToken); + var oldRefreshReplayRejected = IsUnauthorized(oldReplay); + + auth.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = newPrincipal } + }; + await auth.Logout(cancellationToken); + var logoutRejectedAccess = !await IsPrincipalCurrentAsync( + newPrincipal, repository, cancellationToken); + var logoutRefresh = await auth.Refresh( + new Controllers.External.AuthRequest( + rotated.Token, rotated.RefreshToken), + cancellationToken); + var logoutRejectedRefresh = IsUnauthorized(logoutRefresh); + + var adminRevoked = await issuer.CreateSessionAsync( + user, secondProfile, "administrator-revoke-test", cancellationToken); + var adminRevokedPrincipal = ValidateToken( + adminRevoked.AccessToken, validationParameters); + var adminAccounts = CreateAccountsController( + repository, issuer, authorization.Object, newPrincipal); + await adminAccounts.RevokeAnySession( + adminRevoked.SessionId, cancellationToken); + var administratorRevokeRejectedAccess = !await IsPrincipalCurrentAsync( + adminRevokedPrincipal, repository, cancellationToken); + var administratorRevokeRefresh = await auth.Refresh( + new Controllers.External.AuthRequest( + adminRevoked.AccessToken, adminRevoked.RefreshToken), + cancellationToken); + var administratorRevokeRejectedRefresh = IsUnauthorized( + administratorRevokeRefresh); + + var concurrent = await issuer.CreateSessionAsync( + user, secondProfile, "concurrent-refresh-test", cancellationToken); + async Task RefreshConcurrentlyAsync() + { + await using var refreshContext = new Models.ApplicationContext(_contextOptions); + var refreshRepository = new IdentityRepository(refreshContext); + var refreshIssuer = new SessionTokenIssuer(configuration, refreshRepository); + var refreshController = CreateAuthController( + configuration, + validationParameters, + refreshRepository, + refreshIssuer); + var result = await refreshController.Refresh( + new Controllers.External.AuthRequest( + concurrent.AccessToken, concurrent.RefreshToken), + cancellationToken); + return result is OkObjectResult; + } + + var concurrentResults = await Task.WhenAll( + RefreshConcurrentlyAsync(), + RefreshConcurrentlyAsync()); + return new SessionLifecycleSnapshot( + wrongPinRejected, + correctPinRotated, + oldAccessRejected, + newProfileClaimIsActive, + oldRefreshReplayRejected, + logoutRejectedAccess, + logoutRejectedRefresh, + administratorRevokeRejectedAccess, + administratorRevokeRejectedRefresh, + concurrentResults.Count(result => result)); + } + + public async Task RegisterConcurrentlyAsync( + CancellationToken cancellationToken) + { + var configuration = CreateJwtConfiguration(); + var validationParameters = CreateTokenValidationParameters(configuration); + async Task RegisterAsync() + { + await using var registerContext = new Models.ApplicationContext(_contextOptions); + var repository = new IdentityRepository(registerContext); + var issuer = new SessionTokenIssuer(configuration, repository); + var controller = CreateAuthController( + configuration, validationParameters, repository, issuer); + return await controller.Register( + new Controllers.External.LoginData( + "concurrent-password", + IdentityDefaults.Username, + "concurrent-registration", + IdentityDefaults.ProfileName), + cancellationToken); + } + + var results = await Task.WhenAll(RegisterAsync(), RegisterAsync()); + await using var inspect = new Models.ApplicationContext(_contextOptions); + return new ConcurrentRegistrationSnapshot( + results.Count(result => result is OkObjectResult), + results.Count(result => result is ConflictResult), + await inspect.Users.CountAsync(cancellationToken), + await inspect.LoginSessions.CountAsync(cancellationToken)); + } + + public async Task SeedUnsafeScopedDeviceTokenAsync( + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + await using var context = new Models.ApplicationContext(_contextOptions); + context.Users.Add(UserEntity( + IdentityDefaults.UserId, IdentityDefaults.Username, now)); + context.Users.Local.Single().PasswordHash = null; + context.Profiles.Add(ProfileEntity( + IdentityDefaults.ProfileId, IdentityDefaults.UserId, now)); + context.WebDavTokens.Add(new Models.WebDavToken + { + Id = Guid.NewGuid(), + UserId = IdentityDefaults.UserId, + Username = "scoped-device", + TokenHash = "hash", + CreatedAt = now, + Scope = "read", + VirtualRoot = "/Anime", + ExpiresAt = now.AddDays(30) + }); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task AttemptUnsafeDowngradeAsync( + CancellationToken cancellationToken) + { + var rejected = false; + try + { + await using var downContext = new Models.ApplicationContext(_contextOptions); + await downContext.Database.MigrateAsync( + PreviousMigration, cancellationToken); + } + catch (Exception exception) when (IsSafetyRejection(exception)) + { + rejected = true; + } + + await using var inspect = new Models.ApplicationContext(_contextOptions); + var applied = await inspect.Database.GetAppliedMigrationsAsync(cancellationToken); + return new DowngradeSafetySnapshot( + rejected, + applied.Contains("20260829155550_AddHouseholdIdentityAndAccessScopes"), + await inspect.Users.CountAsync(cancellationToken), + await inspect.Profiles.CountAsync(cancellationToken), + await inspect.PlaybackProgresses.CountAsync(cancellationToken), + await inspect.PlaybackPreferences.CountAsync(cancellationToken), + await inspect.WebDavTokens.CountAsync(cancellationToken)); + } + + public async Task ExerciseProfileIsolationAsync( + CancellationToken cancellationToken) + { + var userId = Guid.Parse("71000000-0000-0000-0000-000000000001"); + var firstProfileId = Guid.Parse("72000000-0000-0000-0000-000000000001"); + var secondProfileId = Guid.Parse("72000000-0000-0000-0000-000000000002"); + var animationInfoId = Guid.Parse("73000000-0000-0000-0000-000000000001"); + const string VirtualPath = "/unknown/episode.mkv"; + var now = DateTimeOffset.UtcNow; + await using var context = new Models.ApplicationContext(_contextOptions); + context.Users.Add(new Models.UserAccount + { + Id = userId, + Username = "family", + PasswordHash = "hash", + Role = UserRole.Member, + CreatedAt = now, + UpdatedAt = now + }); + context.Profiles.AddRange( + ProfileEntity(firstProfileId, userId, now, "First"), + ProfileEntity(secondProfileId, userId, now, "Second")); + context.AnimationInfo.Add(new Models.AnimationInfo + { + Id = animationInfoId, + Title = "profile isolation", + Description = string.Empty, + PublishTime = now, + DownloadUrl = string.Empty, + DownloadType = string.Empty, + CachedDownloadData = [], + AdditionalDownloadInfo = string.Empty, + IsDownloadFinished = true, + FileStore = "local", + StorePath = "/profile-isolation" + }); + context.FileMappings.Add(new Models.FileMapping + { + Id = Guid.NewGuid(), + AnimationInfoId = animationInfoId, + VirtualPath = VirtualPath, + PhysicalPath = "/disk/episode.mkv", + FileStore = "local" + }); + await context.SaveChangesAsync(cancellationToken); + + var playback = new PlaybackRepository(context, _contextOptions); + await playback.UpsertProgressAsync( + firstProfileId, animationInfoId, VirtualPath, + 10, 100, false, now, cancellationToken); + await playback.UpsertProgressAsync( + secondProfileId, animationInfoId, VirtualPath, + 70, 100, false, now.AddSeconds(1), cancellationToken); + await playback.UpsertPreferencesAsync(new PlaybackPreferences( + firstProfileId, "zh-Hans", null, "ja", null, true, now), cancellationToken); + await playback.UpsertPreferencesAsync(new PlaybackPreferences( + secondProfileId, "en", null, "en", null, false, now), cancellationToken); + + var chat = new ChatRepository(context); + var firstConversation = await chat.CreateConversationAsync( + firstProfileId, "first", cancellationToken); + await chat.CreateConversationAsync(secondProfileId, "second", cancellationToken); + var firstProgress = await playback.FindProgressAsync( + firstProfileId, animationInfoId, VirtualPath, cancellationToken); + var secondProgress = await playback.FindProgressAsync( + secondProfileId, animationInfoId, VirtualPath, cancellationToken); + var firstPreferences = await playback.GetPreferencesAsync( + firstProfileId, cancellationToken); + var secondPreferences = await playback.GetPreferencesAsync( + secondProfileId, cancellationToken); + var firstConversations = await chat.GetConversationsAsync( + firstProfileId, cancellationToken); + var secondConversations = await chat.GetConversationsAsync( + secondProfileId, cancellationToken); + var crossProfile = await chat.GetConversationWithMessagesAsync( + firstConversation.Id, secondProfileId, cancellationToken); + var firstContinue = await playback.GetContinueWatchingAsync( + firstProfileId, 10, cancellationToken); + var secondContinue = await playback.GetContinueWatchingAsync( + secondProfileId, 10, cancellationToken); + return new ProfileIsolationSnapshot( + firstProgress?.PositionSeconds, + secondProgress?.PositionSeconds, + firstPreferences.SubtitleLanguage, + secondPreferences.SubtitleLanguage, + firstConversations.Count, + secondConversations.Count, + crossProfile is null, + firstContinue.SingleOrDefault()?.Progress.PositionSeconds, + secondContinue.SingleOrDefault()?.Progress.PositionSeconds); + } + + private static Models.UserAccount UserEntity( + Guid id, + string username, + DateTimeOffset now) => new() + { + Id = id, + Username = username, + PasswordHash = "hash", + Role = UserRole.Admin, + IsDisabled = false, + CreatedAt = now, + UpdatedAt = now + }; + + private static IConfiguration CreateJwtConfiguration() => + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["JwtSecret"] = "postgres-integration-secret-long-enough-123456" + }) + .Build(); + + private static TokenValidationParameters CreateTokenValidationParameters( + IConfiguration configuration) => new() + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.ASCII.GetBytes(configuration["JwtSecret"]!)), + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + RequireExpirationTime = true + }; + + private static ClaimsPrincipal ValidateToken( + string token, + TokenValidationParameters validationParameters) => + new JwtSecurityTokenHandler().ValidateToken( + token, validationParameters, out _); + + private static AccountsController CreateAccountsController( + IIdentityRepository repository, + SessionTokenIssuer issuer, + IAuthorizationService authorizationService, + ClaimsPrincipal principal) => new(repository, issuer, authorizationService) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = principal } + } + }; + + private static AuthController CreateAuthController( + IConfiguration configuration, + TokenValidationParameters validationParameters, + IIdentityRepository repository, + SessionTokenIssuer issuer) => new( + configuration, + validationParameters, + repository, + issuer, + NullLogger.Instance); + + private static async Task IsPrincipalCurrentAsync( + ClaimsPrincipal principal, + IIdentityRepository repository, + CancellationToken cancellationToken) + { + if (!principal.TryGetUserId(out var userId) + || !principal.TryGetProfileId(out var profileId) + || !principal.TryGetSessionId(out var sessionId)) + return false; + var authenticated = await repository.GetAuthenticatedSessionAsync( + sessionId, DateTimeOffset.UtcNow, cancellationToken); + return authenticated is not null + && authenticated.User.Id == userId + && authenticated.Profile.Id == profileId + && principal.IsInRole(authenticated.User.Role.ToString()); + } + + private static bool IsUnauthorized(IActionResult result) => + result is UnauthorizedResult or UnauthorizedObjectResult; + + private static bool IsSafetyRejection(Exception exception) => + exception is PostgresException { SqlState: "P0001" } + || exception.InnerException is not null && IsSafetyRejection(exception.InnerException); + + private static Models.UserProfile ProfileEntity( + Guid id, + Guid userId, + DateTimeOffset now, + string name = "Home") => new() + { + Id = id, + UserId = userId, + Name = name, + IsDefault = true, + CreatedAt = now, + UpdatedAt = now + }; + + public async Task MigrateDownAsync( + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + await context.Database.MigrateAsync(PreviousMigration, cancellationToken); + var usersExists = await TableExistsAsync(context, "Users", cancellationToken); + var profilesExists = await TableExistsAsync(context, "Profiles", cancellationToken); + return new HouseholdMigrationDownSnapshot( + await context.Database.SqlQueryRaw( + "SELECT COUNT(*)::integer AS \"Value\" FROM \"PlaybackProgresses\"") + .SingleAsync(cancellationToken), + await context.Database.SqlQueryRaw( + "SELECT COUNT(*)::integer AS \"Value\" FROM \"PlaybackPreferences\"") + .SingleAsync(cancellationToken), + await context.Database.SqlQueryRaw( + "SELECT COUNT(*)::integer AS \"Value\" FROM \"ChatConversations\"") + .SingleAsync(cancellationToken), + await context.Database.SqlQueryRaw( + "SELECT COUNT(*)::integer AS \"Value\" FROM \"WebDavTokens\"") + .SingleAsync(cancellationToken), + usersExists, + profilesExists); + } + + private static async Task TableExistsAsync( + Models.ApplicationContext context, + string tableName, + CancellationToken cancellationToken) + { + await context.Database.OpenConnectionAsync(cancellationToken); + await using var command = context.Database.GetDbConnection().CreateCommand(); + command.CommandText = + "SELECT EXISTS (SELECT 1 FROM information_schema.tables " + + "WHERE table_schema = 'public' AND table_name = @name)"; + var parameter = command.CreateParameter(); + parameter.ParameterName = "name"; + parameter.Value = tableName; + command.Parameters.Add(parameter); + return (bool)(await command.ExecuteScalarAsync(cancellationToken))!; + } +} diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Vfs/ScopedDeviceTokenTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/Vfs/ScopedDeviceTokenTests.cs new file mode 100644 index 0000000..a87a815 --- /dev/null +++ b/SecondDimensionWatcherReDive.IntegrationTest/Vfs/ScopedDeviceTokenTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Moq; +using SecondDimensionWatcherReDive.IntegrationTest.Helpers; +using SecondDimensionWatcherReDive.IntegrationTest.TestData; +using SecondDimensionWatcherReDive.WebDav; + +namespace SecondDimensionWatcherReDive.IntegrationTest.Vfs; + +[TestClass] +public sealed class ScopedDeviceTokenTests +{ + private static readonly HttpMethod PropFindMethod = new("PROPFIND"); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private WebDavWebApplicationFactory _factory = null!; + private HttpClient _client = null!; + + [TestInitialize] + public void Setup() + { + _factory = new WebDavWebApplicationFactory("/Anime"); + _factory.ResetState(); + _client = _factory.CreateBasicAuthClient(); + var visible = WebDavMappingFixtures.NewMapping( + "/Anime/episode.mkv", "/disk/visible.mkv"); + var nested = WebDavMappingFixtures.NewMapping( + "/Anime/Sub/subtitle.srt", "/disk/subtitle.srt"); + var adjacent = WebDavMappingFixtures.NewMapping( + "/Anime2/private.mkv", "/disk/private.mkv"); + _factory.Mappings.AddRange([visible, nested, adjacent]); + _factory.FileStoreMock.Setup(store => store.FileInfoAsync( + visible.PhysicalPath, It.IsAny())) + .ReturnsAsync(WebDavMappingFixtures.InfoFor(visible, 42)); + _factory.FileStoreMock.Setup(store => store.OpenReadStreamAsync( + visible.PhysicalPath, It.IsAny())) + .ReturnsAsync(new MemoryStream([1, 2, 3])); + } + + [TestCleanup] + public void Cleanup() + { + _client.Dispose(); + _factory.Dispose(); + } + + [TestMethod] + public async Task VfsRoot_IsRewritten_AndAdjacentPrefixIsHidden() + { + using var response = await _client.GetAsync("/api/vfs/list?path=/"); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var entries = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.IsNotNull(entries); + CollectionAssert.AreEquivalent( + new[] { "episode.mkv", "Sub" }, + entries.Select(entry => entry.Name).ToArray()); + Assert.IsFalse(entries.Any(entry => entry.Name.Contains("Anime2", StringComparison.Ordinal))); + + using var visible = await _client.GetAsync("/api/vfs/stat?path=/episode.mkv"); + using var adjacent = await _client.GetAsync("/api/vfs/stat?path=/../Anime2/private.mkv"); + Assert.AreEqual(HttpStatusCode.OK, visible.StatusCode); + Assert.AreEqual(HttpStatusCode.BadRequest, adjacent.StatusCode); + } + + [TestMethod] + public async Task WebDavRootAndHrefs_AreRewrittenToDeviceNamespace() + { + using var request = new HttpRequestMessage(PropFindMethod, "/webdav/"); + request.Headers.Add(WebDavConstants.Headers.Depth, "1"); + using var response = await _client.SendAsync(request); + + Assert.AreEqual((HttpStatusCode)207, response.StatusCode); + var multiStatus = await WebDavXmlAssertions.ReadMultiStatusAsync(response); + var hrefs = multiStatus.Responses.Select(item => item.Href).ToArray(); + CollectionAssert.Contains(hrefs, "/webdav/"); + CollectionAssert.Contains(hrefs, "/webdav/episode.mkv"); + CollectionAssert.Contains(hrefs, "/webdav/Sub/"); + Assert.IsFalse(hrefs.Any(href => href.Contains("/Anime", StringComparison.Ordinal))); + + using var file = await _client.GetAsync("/webdav/episode.mkv"); + Assert.AreEqual(HttpStatusCode.OK, file.StatusCode); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, await file.Content.ReadAsByteArrayAsync()); + } + + [TestMethod] + public async Task RevokedDeviceToken_IsRejectedImmediately() + { + Assert.IsTrue(await _factory.DeviceTokenRepository.RevokeByIdAsync( + _factory.DeviceTokenRepository.TokenId, + DateTimeOffset.UtcNow, + CancellationToken.None)); + + using var response = await _client.GetAsync("/api/vfs/stat?path=/episode.mkv"); + using var webDav = await SendRootPropFindAsync(); + + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.AreEqual(HttpStatusCode.Unauthorized, webDav.StatusCode); + } + + [TestMethod] + public async Task ExpiredDeviceToken_IsRejectedByVfsAndWebDav() + { + _factory.DeviceTokenRepository.Expire(); + + using var vfs = await _client.GetAsync("/api/vfs/stat?path=/episode.mkv"); + using var webDav = await SendRootPropFindAsync(); + + Assert.AreEqual(HttpStatusCode.Unauthorized, vfs.StatusCode); + Assert.AreEqual(HttpStatusCode.Unauthorized, webDav.StatusCode); + } + + [TestMethod] + public async Task NonReadDeviceToken_IsRejectedByVfsAndWebDav() + { + _factory.DeviceTokenRepository.SetScope("write"); + + using var vfs = await _client.GetAsync("/api/vfs/stat?path=/episode.mkv"); + using var webDav = await SendRootPropFindAsync(); + + Assert.AreEqual(HttpStatusCode.Unauthorized, vfs.StatusCode); + Assert.AreEqual(HttpStatusCode.Unauthorized, webDav.StatusCode); + } + + private async Task SendRootPropFindAsync() + { + using var request = new HttpRequestMessage(PropFindMethod, "/webdav/"); + request.Headers.Add(WebDavConstants.Headers.Depth, "0"); + return await _client.SendAsync(request); + } + + private sealed record VfsEntryDto( + string Name, + bool IsDirectory, + long? Size, + DateTimeOffset? LastModifiedUtc); +} diff --git a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs index ae5cf79..ed7f969 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using Moq; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileStore; using SecondDimensionWatcherReDive.Framework.Tasks; @@ -28,6 +29,11 @@ internal sealed class WebDavWebApplicationFactory : WebApplicationFactory FileStoreMock { get; } = new(); public Mock FileStoreProviderMock { get; } = new(); public Helpers.FakeFileMappingRepository MappingRepository { get; } + public FakeWebDavTokenRepository DeviceTokenRepository { get; } private readonly object _mappingsLock = new(); + private readonly UserRole _role; + private bool _sessionRevoked; - public WebDavWebApplicationFactory() + public WebDavWebApplicationFactory( + string virtualRoot = "/", + UserRole role = UserRole.Admin) { + _role = role; MappingRepository = new Helpers.FakeFileMappingRepository(Mappings); + DeviceTokenRepository = new FakeWebDavTokenRepository( + UserId, + TestUserName, + BCrypt.Net.BCrypt.HashPassword(TestPassword), + virtualRoot); FileStoreMock.SetupGet(s => s.Name).Returns("local"); FileStoreProviderMock .Setup(p => p.GetRequiredClient(It.IsAny())) @@ -121,14 +138,52 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); + services.RemoveAll(); services.RemoveAll(); services.AddSingleton(FileStoreMock.Object); services.AddSingleton(FileStoreProviderMock.Object); services.AddSingleton(_ => MappingRepository); services.AddSingleton(_ => new FakeFileExplorer(Mappings, FileStoreMock.Object, MappingRepository)); - services.AddSingleton(_ => - new FakeWebDavTokenRepository(TestUserName, BCrypt.Net.BCrypt.HashPassword(TestPassword))); + services.AddSingleton(_ => DeviceTokenRepository); + var identityRepository = new Mock(); + var user = new UserAccount( + UserId, + TestUserName, + "hash", + _role, + false, + AuthenticatedAt, + AuthenticatedAt); + var profile = new UserProfile( + ProfileId, + UserId, + "Test", + null, + null, + true, + AuthenticatedAt, + AuthenticatedAt); + var session = new UserSession( + SessionId, + UserId, + ProfileId, + "hash", + "integration-test", + AuthenticatedAt, + AuthenticatedAt, + AuthenticatedAt, + DateTimeOffset.UtcNow.AddDays(1), + null); + identityRepository.Setup(repository => repository.FindUserByIdAsync( + UserId, It.IsAny())) + .ReturnsAsync(user); + identityRepository.Setup(repository => repository.GetAuthenticatedSessionAsync( + SessionId, It.IsAny(), It.IsAny())) + .ReturnsAsync(() => _sessionRevoked + ? null + : new AuthenticatedSession(user, profile, session)); + services.AddSingleton(identityRepository.Object); services.AddSingleton(); }); } @@ -160,13 +215,24 @@ public HttpClient CreateBasicAuthClient(string user = TestUserName, string pass public HttpClient CreateUnauthenticatedClient() => CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + public void RevokeLoginSession() => _sessionRevoked = true; + public HttpClient CreateJwtClient() { var client = CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); var keyBytes = Encoding.ASCII.GetBytes(JwtSecret); var creds = new SigningCredentials(new SymmetricSecurityKey(keyBytes), SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( - claims: new[] { new Claim(ClaimTypes.Name, TestUserName) }, + claims: + [ + new Claim(ClaimTypes.Name, TestUserName), + new Claim(ClaimTypes.Role, _role.ToString()), + new Claim(IdentityClaimTypes.UserId, UserId.ToString()), + new Claim(IdentityClaimTypes.ProfileId, ProfileId.ToString()), + new Claim(IdentityClaimTypes.SessionId, SessionId.ToString()), + new Claim(IdentityClaimTypes.AuthenticatedAt, + AuthenticatedAt.ToUnixTimeSeconds().ToString()) + ], expires: DateTime.UtcNow.AddMinutes(10), signingCredentials: creds); var jwt = new JwtSecurityTokenHandler().WriteToken(token); diff --git a/SecondDimensionWatcherReDive.Test/AccountsControllerTests.cs b/SecondDimensionWatcherReDive.Test/AccountsControllerTests.cs new file mode 100644 index 0000000..e4f95cc --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/AccountsControllerTests.cs @@ -0,0 +1,193 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Moq; +using SecondDimensionWatcherReDive.Auth; +using SecondDimensionWatcherReDive.Controllers; +using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Framework.Authorization; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class AccountsControllerTests +{ + private static readonly Guid UserId = Guid.Parse("61000000-0000-0000-0000-000000000001"); + private static readonly Guid ActiveProfileId = Guid.Parse("62000000-0000-0000-0000-000000000001"); + private static readonly Guid ProtectedProfileId = Guid.Parse("62000000-0000-0000-0000-000000000002"); + + [TestMethod] + public async Task ProfileA_CannotClearProfileBPin_WithoutPinOrRecentAuthentication() + { + var repository = new Mock(); + repository.Setup(candidate => candidate.FindProfileAsync( + ProtectedProfileId, It.IsAny())) + .ReturnsAsync(Profile(ProtectedProfileId, BCrypt.Net.BCrypt.HashPassword("2468"))); + var authorization = new Mock(); + authorization.Setup(service => service.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + AccessPolicies.RecentAuthentication)) + .ReturnsAsync(AuthorizationResult.Failed()); + var controller = CreateController(repository, authorization); + + var result = await controller.UpdateProfile( + ProtectedProfileId, + new UpdateProfileRequest( + "Protected", + null, + Pin: string.Empty, + CurrentPin: null, + ReplacePin: true), + CancellationToken.None); + + Assert.IsInstanceOfType(result); + repository.Verify(candidate => candidate.UpdateProfileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProfileBPin_AllowsIntentionalPinReplacement() + { + var repository = new Mock(); + repository.Setup(candidate => candidate.FindProfileAsync( + ProtectedProfileId, It.IsAny())) + .ReturnsAsync(Profile(ProtectedProfileId, BCrypt.Net.BCrypt.HashPassword("2468"))); + repository.Setup(candidate => candidate.UpdateProfileAsync( + ProtectedProfileId, + UserId, + "Protected", + null, + It.IsAny(), + true, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + var authorization = new Mock(); + authorization.Setup(service => service.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + AccessPolicies.RecentAuthentication)) + .ReturnsAsync(AuthorizationResult.Failed()); + var controller = CreateController(repository, authorization); + + var result = await controller.UpdateProfile( + ProtectedProfileId, + new UpdateProfileRequest( + "Protected", + null, + Pin: "1357", + CurrentPin: "2468", + ReplacePin: true), + CancellationToken.None); + + Assert.IsInstanceOfType(result); + repository.Verify(candidate => candidate.UpdateProfileAsync( + ProtectedProfileId, + UserId, + "Protected", + null, + It.Is(hash => hash != null + && BCrypt.Net.BCrypt.Verify("1357", hash)), + true, + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task CreateProfile_DuplicateName_ReturnsConflict() + { + var repository = new Mock(); + repository.Setup(candidate => candidate.AddProfileAsync( + It.IsAny(), It.IsAny())) + .ThrowsAsync(new IdentityConflictException("duplicate")); + var controller = CreateController( + repository, new Mock()); + + var result = await controller.CreateProfile( + new CreateProfileRequest("Protected", null, null), + CancellationToken.None); + + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public async Task UpdateProfile_DuplicateSiblingName_ReturnsConflict() + { + var repository = new Mock(); + repository.Setup(candidate => candidate.FindProfileAsync( + ActiveProfileId, It.IsAny())) + .ReturnsAsync(Profile(ActiveProfileId, null)); + repository.Setup(candidate => candidate.UpdateProfileAsync( + ActiveProfileId, + UserId, + "Protected", + null, + null, + false, + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new IdentityConflictException("duplicate")); + var controller = CreateController( + repository, new Mock()); + + var result = await controller.UpdateProfile( + ActiveProfileId, + new UpdateProfileRequest("Protected", null, null), + CancellationToken.None); + + Assert.IsInstanceOfType(result); + } + + private static AccountsController CreateController( + Mock repository, + Mock authorization) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["JwtSecret"] = "unit-test-secret-long-enough-for-hmac-1234" + }) + .Build(); + return new AccountsController( + repository.Object, + new SessionTokenIssuer(configuration, repository.Object), + authorization.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(IdentityClaimTypes.UserId, UserId.ToString()), + new Claim(IdentityClaimTypes.ProfileId, ActiveProfileId.ToString()), + new Claim(ClaimTypes.Role, nameof(UserRole.Member)) + ], "test")) + } + } + }; + } + + private static UserProfile Profile(Guid id, string? pinHash) => new( + id, + UserId, + "Protected", + null, + pinHash, + false, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow); +} diff --git a/SecondDimensionWatcherReDive.Test/AnimationInfoControllerTests.cs b/SecondDimensionWatcherReDive.Test/AnimationInfoControllerTests.cs index 1cf8e25..0a43dff 100644 --- a/SecondDimensionWatcherReDive.Test/AnimationInfoControllerTests.cs +++ b/SecondDimensionWatcherReDive.Test/AnimationInfoControllerTests.cs @@ -1,8 +1,10 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Distributed; using Moq; using SecondDimensionWatcherReDive.Controllers; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.FileDownload; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Utils.FileStore; @@ -18,6 +20,7 @@ public class AnimationInfoControllerTests private Mock _providerMock = null!; private Mock _downloadClientMock = null!; private Mock _fileMapperMock = null!; + private Mock _authorizationServiceMock = null!; private AnimationInfoController _controller = null!; [TestInitialize] @@ -29,6 +32,7 @@ public void Setup() _providerMock = new Mock(); _downloadClientMock = new Mock(); _fileMapperMock = new Mock(); + _authorizationServiceMock = new Mock(); _providerMock .Setup(p => p.GetRequiredClient(It.IsAny())) @@ -39,7 +43,8 @@ public void Setup() _fileMappingRepoMock.Object, _cacheMock.Object, _providerMock.Object, - _fileMapperMock.Object) + _fileMapperMock.Object, + _authorizationServiceMock.Object) { ControllerContext = new ControllerContext { @@ -341,6 +346,35 @@ public async Task CancelDownload_Success_SetsIsDownloadTrackedFalseAndUpdates() cancellationAttemptId.Value, It.Is(token => token.CanBeCanceled && !token.IsCancellationRequested)), Times.Once); + _authorizationServiceMock.Verify(service => service.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CancelDownload_RemoveFileWithoutRecentAdministrator_ReturnsForbidBeforeLookup() + { + var id = Guid.NewGuid(); + _authorizationServiceMock.Setup(service => service.AuthorizeAsync( + It.IsAny(), + null, + AccessPolicies.RecentAdministrator)) + .ReturnsAsync(AuthorizationResult.Failed()); + + var result = await _controller.CancelDownload( + id, removeFile: true, CancellationToken.None); + + Assert.IsInstanceOfType(result); + _repoMock.Verify(repository => repository.FindByIdAsync( + It.IsAny(), It.IsAny()), Times.Never); + _downloadClientMock.Verify(client => client.CancelDownloadTaskAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); } [TestMethod] diff --git a/SecondDimensionWatcherReDive.Test/BasicAuthenticationHandlerTests.cs b/SecondDimensionWatcherReDive.Test/BasicAuthenticationHandlerTests.cs index fa0926d..fb5d830 100644 --- a/SecondDimensionWatcherReDive.Test/BasicAuthenticationHandlerTests.cs +++ b/SecondDimensionWatcherReDive.Test/BasicAuthenticationHandlerTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Options; using Moq; using SecondDimensionWatcherReDive.Auth; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; namespace SecondDimensionWatcherReDive.Test; @@ -16,6 +17,7 @@ public class BasicAuthenticationHandlerTests { private const string ValidUser = "alice"; private const string ValidPassword = "correct-horse"; + private static readonly Guid UserId = Guid.Parse("10000000-0000-0000-0000-000000000001"); private string _hash = null!; [TestInitialize] @@ -35,6 +37,12 @@ public void Setup() var services = new ServiceCollection(); services.AddSingleton(repo.Object); + var identityRepo = new Mock(); + identityRepo.Setup(r => r.FindUserByIdAsync(UserId, It.IsAny())) + .ReturnsAsync(new UserAccount( + UserId, "admin", "hash", UserRole.Admin, false, + DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + services.AddSingleton(identityRepo.Object); var provider = services.BuildServiceProvider(); var optionsMonitor = new Mock>(); @@ -53,8 +61,14 @@ public void Setup() return (handler, httpContext, repo); } - private WebDavToken SeededToken(string username = ValidUser) => - new(Guid.NewGuid(), username, _hash, null, DateTimeOffset.UtcNow); + private WebDavToken SeededToken( + string username = ValidUser, + string scope = "read", + string root = "/Anime", + DateTimeOffset? expiresAt = null, + DateTimeOffset? revokedAt = null) => + new(Guid.NewGuid(), UserId, username, _hash, null, DateTimeOffset.UtcNow, + scope, root, expiresAt ?? DateTimeOffset.UtcNow.AddDays(1), revokedAt); private static string BasicHeader(string user, string password) => "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{password}")); @@ -116,6 +130,28 @@ public async Task ValidCredentials_Succeed() var result = await handler.AuthenticateAsync(); Assert.IsTrue(result.Succeeded); Assert.AreEqual(ValidUser, result.Principal!.Identity!.Name); + Assert.AreEqual("/Anime", result.Principal.FindFirst(IdentityClaimTypes.VirtualRoot)?.Value); + Assert.AreEqual("read", result.Principal.FindFirst(IdentityClaimTypes.DeviceScope)?.Value); + Assert.AreEqual(UserId.ToString(), result.Principal.FindFirst(IdentityClaimTypes.UserId)?.Value); + } + + [TestMethod] + public async Task RevokedExpiredOrNonReadToken_Fails() + { + var revoked = await CreateHandlerAsync( + BasicHeader(ValidUser, ValidPassword), + SeededToken(revokedAt: DateTimeOffset.UtcNow)); + Assert.IsFalse((await revoked.handler.AuthenticateAsync()).Succeeded); + + var expired = await CreateHandlerAsync( + BasicHeader(ValidUser, ValidPassword), + SeededToken(expiresAt: DateTimeOffset.UtcNow.AddMinutes(-1))); + Assert.IsFalse((await expired.handler.AuthenticateAsync()).Succeeded); + + var write = await CreateHandlerAsync( + BasicHeader(ValidUser, ValidPassword), + SeededToken(scope: "write")); + Assert.IsFalse((await write.handler.AuthenticateAsync()).Succeeded); } [TestMethod] diff --git a/SecondDimensionWatcherReDive.Test/ConversationTitleGeneratorTests.cs b/SecondDimensionWatcherReDive.Test/ConversationTitleGeneratorTests.cs index 709ae78..404954a 100644 --- a/SecondDimensionWatcherReDive.Test/ConversationTitleGeneratorTests.cs +++ b/SecondDimensionWatcherReDive.Test/ConversationTitleGeneratorTests.cs @@ -11,6 +11,8 @@ namespace SecondDimensionWatcherReDive.Test; [TestClass] public class ConversationTitleGeneratorTests { + private static readonly Guid ProfileId = Guid.Parse("10000000-0000-0000-0000-000000000001"); + private static IServiceProvider ServicesWith(IAIEngine? engine) { var services = new ServiceCollection(); @@ -125,15 +127,18 @@ public async Task TryAutoTitle_SavesTitleWhenEligible() var convId = Guid.NewGuid(); var engine = EngineReturning("Anime subscription"); var repo = new Mock(); - repo.Setup(r => r.GetConversationWithMessagesAsync(convId, It.IsAny())) + repo.Setup(r => r.GetConversationWithMessagesAsync( + convId, ProfileId, It.IsAny())) .ReturnsAsync(new ChatConversationDetail(convId, null, DateTimeOffset.Now, DateTimeOffset.Now, [])); var gen = new ConversationTitleGenerator(ServicesWith(engine.Object), repo.Object, NullLogger.Instance); - await gen.TryAutoTitleAsync(convId, "请订阅新番", "好的", null, CancellationToken.None); + await gen.TryAutoTitleAsync( + convId, ProfileId, "请订阅新番", "好的", null, CancellationToken.None); - repo.Verify(r => r.UpdateConversationTitleAsync(convId, "Anime subscription", It.IsAny()), + repo.Verify(r => r.UpdateConversationTitleAsync( + convId, ProfileId, "Anime subscription", It.IsAny()), Times.Once); } @@ -143,15 +148,17 @@ public async Task TryAutoTitle_SkipsWhenTitleAlreadySet() var convId = Guid.NewGuid(); var engine = EngineReturning("Generated"); var repo = new Mock(); - repo.Setup(r => r.GetConversationWithMessagesAsync(convId, It.IsAny())) + repo.Setup(r => r.GetConversationWithMessagesAsync( + convId, ProfileId, It.IsAny())) .ReturnsAsync(new ChatConversationDetail(convId, "User chose this", DateTimeOffset.Now, DateTimeOffset.Now, [])); var gen = new ConversationTitleGenerator(ServicesWith(engine.Object), repo.Object, NullLogger.Instance); - await gen.TryAutoTitleAsync(convId, "u", "a", null, CancellationToken.None); + await gen.TryAutoTitleAsync(convId, ProfileId, "u", "a", null, CancellationToken.None); - repo.Verify(r => r.UpdateConversationTitleAsync(It.IsAny(), It.IsAny(), It.IsAny()), + repo.Verify(r => r.UpdateConversationTitleAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -161,15 +168,17 @@ public async Task TryAutoTitle_DoesNotSaveOnEmptyOutput() var convId = Guid.NewGuid(); var engine = EngineReturning(" "); var repo = new Mock(); - repo.Setup(r => r.GetConversationWithMessagesAsync(convId, It.IsAny())) + repo.Setup(r => r.GetConversationWithMessagesAsync( + convId, ProfileId, It.IsAny())) .ReturnsAsync(new ChatConversationDetail(convId, null, DateTimeOffset.Now, DateTimeOffset.Now, [])); var gen = new ConversationTitleGenerator(ServicesWith(engine.Object), repo.Object, NullLogger.Instance); - await gen.TryAutoTitleAsync(convId, "u", "a", null, CancellationToken.None); + await gen.TryAutoTitleAsync(convId, ProfileId, "u", "a", null, CancellationToken.None); - repo.Verify(r => r.UpdateConversationTitleAsync(It.IsAny(), It.IsAny(), It.IsAny()), + repo.Verify(r => r.UpdateConversationTitleAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -182,15 +191,17 @@ public async Task TryAutoTitle_SwallowsEngineExceptions() .Returns(Throwing()); var repo = new Mock(); - repo.Setup(r => r.GetConversationWithMessagesAsync(convId, It.IsAny())) + repo.Setup(r => r.GetConversationWithMessagesAsync( + convId, ProfileId, It.IsAny())) .ReturnsAsync(new ChatConversationDetail(convId, null, DateTimeOffset.Now, DateTimeOffset.Now, [])); var gen = new ConversationTitleGenerator(ServicesWith(engine.Object), repo.Object, NullLogger.Instance); - await gen.TryAutoTitleAsync(convId, "u", "a", null, CancellationToken.None); + await gen.TryAutoTitleAsync(convId, ProfileId, "u", "a", null, CancellationToken.None); - repo.Verify(r => r.UpdateConversationTitleAsync(It.IsAny(), It.IsAny(), It.IsAny()), + repo.Verify(r => r.UpdateConversationTitleAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); static async IAsyncEnumerable Throwing() diff --git a/SecondDimensionWatcherReDive.Test/DevicePathScopeTests.cs b/SecondDimensionWatcherReDive.Test/DevicePathScopeTests.cs new file mode 100644 index 0000000..a460404 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/DevicePathScopeTests.cs @@ -0,0 +1,41 @@ +using SecondDimensionWatcherReDive.Auth; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class DevicePathScopeTests +{ + [TestMethod] + public void ScopedRoot_MapsPublicRootAndChildren() + { + Assert.IsTrue(DevicePathScope.TryMapPublicToInternal( + "/", "/Anime", out var publicRoot, out var internalRoot)); + Assert.AreEqual("/", publicRoot); + Assert.AreEqual("/Anime", internalRoot); + + Assert.IsTrue(DevicePathScope.TryMapPublicToInternal( + "/Season/episode.mkv", "/Anime", out var publicChild, out var internalChild)); + Assert.AreEqual("/Season/episode.mkv", publicChild); + Assert.AreEqual("/Anime/Season/episode.mkv", internalChild); + } + + [TestMethod] + public void InternalMapping_UsesPathSegments_NotStringPrefixes() + { + Assert.IsTrue(DevicePathScope.TryMapInternalToPublic( + "/Anime/episode.mkv", "/Anime", out var publicPath)); + Assert.AreEqual("/episode.mkv", publicPath); + + Assert.IsFalse(DevicePathScope.TryMapInternalToPublic( + "/Anime2/private.mkv", "/Anime", out _)); + } + + [TestMethod] + public void TraversalOrBackslash_IsRejected() + { + Assert.IsFalse(DevicePathScope.TryMapPublicToInternal( + "/../Anime2/private.mkv", "/Anime", out _, out _)); + Assert.IsFalse(DevicePathScope.TryMapPublicToInternal( + "/Season\\private.mkv", "/Anime", out _, out _)); + } +} diff --git a/SecondDimensionWatcherReDive.Test/ManageDownloadsToolAuthorizationTests.cs b/SecondDimensionWatcherReDive.Test/ManageDownloadsToolAuthorizationTests.cs new file mode 100644 index 0000000..918c2b2 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/ManageDownloadsToolAuthorizationTests.cs @@ -0,0 +1,154 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Moq; +using SecondDimensionWatcherReDive.AI.Models; +using SecondDimensionWatcherReDive.Chat.Tools; +using SecondDimensionWatcherReDive.Framework.Authorization; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.FileDownload; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public class ManageDownloadsToolAuthorizationTests +{ + [TestMethod] + public async Task CancelWithRemoveFile_MemberIsRejectedBeforeDownloadClientCall() + { + var (tool, repository, client, authorization, animation) = CreateTool(); + authorization.Setup(service => service.AuthorizeAsync( + It.IsAny(), + null, + AccessPolicies.RecentAdministrator)) + .ReturnsAsync(AuthorizationResult.Failed()); + + var result = await tool.ExecuteAsync(Arguments( + animation.Id, removeFile: true), CancellationToken.None); + + var failure = Assert.IsInstanceOfType(result); + StringAssert.Contains(failure.Error, "administrator"); + repository.Verify(repo => repo.TryBeginCancelDownloadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + client.Verify(downloadClient => downloadClient.CancelDownloadTaskAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CancelWithoutRemoveFile_MemberCanCancel() + { + var (tool, repository, client, authorization, animation) = CreateTool(); + repository.Setup(repo => repo.TryBeginCancelDownloadAsync( + animation.Id, + animation.DownloadAttemptId, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + client.Setup(downloadClient => downloadClient.CancelDownloadTaskAsync( + animation.Id, + animation.DownloadUrl, + animation.CachedDownloadData, + animation.AdditionalDownloadInfo, + false, + CancellationToken.None)) + .ReturnsAsync(new CancelDownloadResult(true, false)); + var result = await tool.ExecuteAsync(Arguments( + animation.Id, removeFile: false), CancellationToken.None); + + var success = Assert.IsInstanceOfType>(result); + Assert.IsTrue(success.Result); + authorization.Verify(service => service.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + client.Verify(downloadClient => downloadClient.CancelDownloadTaskAsync( + animation.Id, + animation.DownloadUrl, + animation.CachedDownloadData, + animation.AdditionalDownloadInfo, + false, + CancellationToken.None), Times.Once); + } + + private static ( + ManageDownloadsTool Tool, + Mock Repository, + Mock Client, + Mock Authorization, + AnimationInfo Animation) CreateTool() + { + var animation = new AnimationInfo( + Guid.NewGuid(), + "Title", + "Description", + DateTimeOffset.UtcNow, + "https://example.invalid/item.torrent", + "torrent", + [], + "cached", + true, + default, + default, + false, + null, + null, + null, + null, + null, + null, + false, + 0) + { + DownloadAttemptId = Guid.NewGuid() + }; + var repository = new Mock(); + repository.Setup(repo => repo.FindByIdAsync( + animation.Id, CancellationToken.None)) + .ReturnsAsync(animation); + var mappingRepository = new Mock(); + mappingRepository.Setup(repo => repo.TryFinalizeDownloadCancellationAsync( + animation.Id, + animation.DownloadAttemptId, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + var client = new Mock(); + var provider = new Mock(); + provider.Setup(value => value.GetClient(animation.DownloadType)) + .Returns(client.Object); + var httpContextAccessor = new HttpContextAccessor + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Role, nameof(UserRole.Member))], + "test")) + } + }; + var authorization = new Mock(); + var tool = new ManageDownloadsTool( + repository.Object, + mappingRepository.Object, + provider.Object, + httpContextAccessor, + authorization.Object); + return (tool, repository, client, authorization, animation); + } + + private static JsonElement Arguments(Guid animationId, bool removeFile) => + JsonSerializer.SerializeToElement( + new ManageDownloadsParams( + ManageDownloadsAction.Cancel, + animationId.ToString(), + removeFile), + ToolJsonOptions.Options); +} diff --git a/SecondDimensionWatcherReDive.Test/PlaybackControllerTests.cs b/SecondDimensionWatcherReDive.Test/PlaybackControllerTests.cs index 31486bf..0275277 100644 --- a/SecondDimensionWatcherReDive.Test/PlaybackControllerTests.cs +++ b/SecondDimensionWatcherReDive.Test/PlaybackControllerTests.cs @@ -4,6 +4,7 @@ using Moq; using SecondDimensionWatcherReDive.Controllers; using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; using DataAnimationInfo = SecondDimensionWatcherReDive.Framework.DataRepository.AnimationInfo; using DataAnimation = SecondDimensionWatcherReDive.Framework.DataRepository.Animation; @@ -36,7 +37,7 @@ public void Setup() HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( - [new Claim("Id", UserId.ToString())], + [new Claim(IdentityClaimTypes.ProfileId, UserId.ToString())], "test")) } } diff --git a/SecondDimensionWatcherReDive.Test/WebDavTokenControllerTests.cs b/SecondDimensionWatcherReDive.Test/WebDavTokenControllerTests.cs index e8eed94..9449e21 100644 --- a/SecondDimensionWatcherReDive.Test/WebDavTokenControllerTests.cs +++ b/SecondDimensionWatcherReDive.Test/WebDavTokenControllerTests.cs @@ -1,7 +1,10 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; using SecondDimensionWatcherReDive.Controllers; using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; namespace SecondDimensionWatcherReDive.Test; @@ -10,7 +13,10 @@ namespace SecondDimensionWatcherReDive.Test; public class WebDavTokenControllerTests { private Mock _repo = null!; + private Mock _identityRepo = null!; + private Mock _mappingRepo = null!; private WebDavTokenController _controller = null!; + private readonly Guid _userId = Guid.Parse("10000000-0000-0000-0000-000000000001"); [TestInitialize] public void Setup() @@ -18,7 +24,27 @@ public void Setup() _repo = new Mock(); _repo.Setup(r => r.ExistsByUsernameAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(false); - _controller = new WebDavTokenController(_repo.Object); + _identityRepo = new Mock(); + _identityRepo.Setup(r => r.FindUserByIdAsync(_userId, It.IsAny())) + .ReturnsAsync(new UserAccount( + _userId, "admin", "hash", UserRole.Admin, false, + DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + _mappingRepo = new Mock(); + _controller = new WebDavTokenController( + _repo.Object, _identityRepo.Object, _mappingRepo.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(IdentityClaimTypes.UserId, _userId.ToString()), + new Claim(ClaimTypes.Role, nameof(UserRole.Admin)) + ], "test")) + } + } + }; } [TestMethod] @@ -26,8 +52,8 @@ public async Task ListTokens_ReturnsSummariesWithoutHash() { var seeded = new List { - new(Guid.NewGuid(), "alice", "hash-1", "first key", DateTimeOffset.UtcNow.AddMinutes(-1)), - new(Guid.NewGuid(), "bob", "hash-2", null, DateTimeOffset.UtcNow) + Token("alice", "hash-1", "first key", DateTimeOffset.UtcNow.AddMinutes(-1)), + Token("bob", "hash-2", null, DateTimeOffset.UtcNow) }; _repo.Setup(r => r.GetAllOrderedAsync(It.IsAny())) .ReturnsAsync(seeded); @@ -63,6 +89,10 @@ public async Task CreateToken_AutoGeneratesUsernameWhenMissing() Assert.AreNotEqual(payload.Token, captured.TokenHash, "TokenHash must not be plaintext."); Assert.IsTrue(BCrypt.Net.BCrypt.Verify(payload.Token, captured.TokenHash)); Assert.IsNull(captured.Description); + Assert.AreEqual(_userId, captured.UserId); + Assert.AreEqual("read", captured.Scope); + Assert.AreEqual("/", captured.VirtualRoot); + Assert.IsTrue(captured.ExpiresAt > DateTimeOffset.UtcNow.AddDays(364)); } [TestMethod] @@ -116,7 +146,8 @@ public async Task CreateToken_ReturnsConflictWhenUsernameTaken() [TestMethod] public async Task DeleteToken_ReturnsNotFoundWhenMissing() { - _repo.Setup(r => r.RemoveByIdAsync(It.IsAny(), It.IsAny())) + _repo.Setup(r => r.RevokeByIdAsync( + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(false); var response = await _controller.DeleteToken(Guid.NewGuid(), CancellationToken.None); Assert.IsInstanceOfType(response, typeof(NotFoundResult)); @@ -125,9 +156,18 @@ public async Task DeleteToken_ReturnsNotFoundWhenMissing() [TestMethod] public async Task DeleteToken_ReturnsNoContentOnSuccess() { - _repo.Setup(r => r.RemoveByIdAsync(It.IsAny(), It.IsAny())) + _repo.Setup(r => r.RevokeByIdAsync( + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); var response = await _controller.DeleteToken(Guid.NewGuid(), CancellationToken.None); Assert.IsInstanceOfType(response, typeof(NoContentResult)); } + + private WebDavToken Token( + string username, + string hash, + string? description, + DateTimeOffset createdAt) => + new(Guid.NewGuid(), _userId, username, hash, description, createdAt, + "read", "/", createdAt.AddYears(1), null); } diff --git a/SecondDimensionWatcherReDive/Auth/BasicAuthenticationHandler.cs b/SecondDimensionWatcherReDive/Auth/BasicAuthenticationHandler.cs index ef43345..0fd6778 100644 --- a/SecondDimensionWatcherReDive/Auth/BasicAuthenticationHandler.cs +++ b/SecondDimensionWatcherReDive/Auth/BasicAuthenticationHandler.cs @@ -4,6 +4,7 @@ using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; namespace SecondDimensionWatcherReDive.Auth; @@ -49,7 +50,17 @@ protected override async Task HandleAuthenticateAsync() var repository = Context.RequestServices.GetRequiredService(); var record = await repository.FindByUsernameAsync(username, Context.RequestAborted); - if (record is null) + var now = DateTimeOffset.UtcNow; + if (record is null + || record.RevokedAt is not null + || record.ExpiresAt is { } expiresAt && expiresAt <= now + || !string.Equals(record.Scope, "read", StringComparison.Ordinal) + || !DevicePathScope.TryNormalizeAbsolutePath(record.VirtualRoot, out var virtualRoot)) + return AuthenticateResult.Fail("Invalid credentials."); + + var identityRepository = Context.RequestServices.GetRequiredService(); + var user = await identityRepository.FindUserByIdAsync(record.UserId, Context.RequestAborted); + if (user is null || user.IsDisabled) return AuthenticateResult.Fail("Invalid credentials."); bool verified; @@ -65,7 +76,15 @@ protected override async Task HandleAuthenticateAsync() if (!verified) return AuthenticateResult.Fail("Invalid credentials."); - var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, username)], Scheme.Name); + var identity = new ClaimsIdentity( + [ + new Claim(ClaimTypes.Name, username), + new Claim(ClaimTypes.Role, user.Role.ToString()), + new Claim(IdentityClaimTypes.UserId, user.Id.ToString()), + new Claim(IdentityClaimTypes.DeviceTokenId, record.Id.ToString()), + new Claim(IdentityClaimTypes.DeviceScope, record.Scope), + new Claim(IdentityClaimTypes.VirtualRoot, virtualRoot) + ], Scheme.Name); var principal = new ClaimsPrincipal(identity); return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); } diff --git a/SecondDimensionWatcherReDive/Auth/DevicePathScope.cs b/SecondDimensionWatcherReDive/Auth/DevicePathScope.cs new file mode 100644 index 0000000..10fd1fc --- /dev/null +++ b/SecondDimensionWatcherReDive/Auth/DevicePathScope.cs @@ -0,0 +1,95 @@ +using System.Security.Claims; +using SecondDimensionWatcherReDive.Framework.Authorization; + +namespace SecondDimensionWatcherReDive.Auth; + +internal static class DevicePathScope +{ + public static string GetVirtualRoot(ClaimsPrincipal principal) => + TryNormalizeAbsolutePath( + principal.FindFirst(IdentityClaimTypes.VirtualRoot)?.Value, + out var root) + ? root + : "/"; + + public static bool TryNormalizeAbsolutePath(string? raw, out string normalized) + { + if (string.IsNullOrEmpty(raw)) + { + normalized = "/"; + return true; + } + + if (!raw.StartsWith("/", StringComparison.Ordinal) + || raw.Contains('\\', StringComparison.Ordinal) + || raw.Any(char.IsControl)) + { + normalized = string.Empty; + return false; + } + + var segments = raw.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Any(segment => segment is "." or "..")) + { + normalized = string.Empty; + return false; + } + + normalized = segments.Length == 0 ? "/" : "/" + string.Join('/', segments); + return true; + } + + public static bool TryMapPublicToInternal( + string? publicPath, + string virtualRoot, + out string normalizedPublicPath, + out string internalPath) + { + if (!TryNormalizeAbsolutePath(publicPath, out normalizedPublicPath) + || !TryNormalizeAbsolutePath(virtualRoot, out var root)) + { + internalPath = string.Empty; + return false; + } + + internalPath = root switch + { + "/" => normalizedPublicPath, + _ when normalizedPublicPath == "/" => root, + _ => root + normalizedPublicPath + }; + return true; + } + + public static bool TryMapInternalToPublic( + string internalPath, + string virtualRoot, + out string publicPath) + { + publicPath = string.Empty; + if (!TryNormalizeAbsolutePath(internalPath, out var normalizedInternal) + || !TryNormalizeAbsolutePath(virtualRoot, out var root)) + return false; + + if (root == "/") + { + publicPath = normalizedInternal; + return true; + } + + if (normalizedInternal == root) + { + publicPath = "/"; + return true; + } + + // Include the slash in the prefix: /Anime is a parent of /Anime/file, + // but never of /Anime2/file. + var rootedPrefix = root + "/"; + if (!normalizedInternal.StartsWith(rootedPrefix, StringComparison.Ordinal)) + return false; + + publicPath = normalizedInternal[root.Length..]; + return true; + } +} diff --git a/SecondDimensionWatcherReDive/Auth/SessionTokenIssuer.cs b/SecondDimensionWatcherReDive/Auth/SessionTokenIssuer.cs new file mode 100644 index 0000000..b9df7d3 --- /dev/null +++ b/SecondDimensionWatcherReDive/Auth/SessionTokenIssuer.cs @@ -0,0 +1,133 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.IdentityModel.Tokens; +using SecondDimensionWatcherReDive.Framework.Authorization; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Auth; + +internal sealed record IssuedSessionTokens( + string AccessToken, + string RefreshToken, + Guid SessionId, + Guid ProfileId); + +internal sealed class SessionTokenIssuer( + IConfiguration configuration, + IIdentityRepository identityRepository) +{ + internal static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(10); + internal static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30); + + public async Task CreateSessionAsync( + UserAccount user, + UserProfile profile, + string? deviceName, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var refreshToken = GenerateRefreshToken(); + var session = new UserSession( + Guid.NewGuid(), + user.Id, + profile.Id, + HashRefreshToken(refreshToken), + NormalizeDeviceName(deviceName), + now, + now, + now, + now + RefreshTokenLifetime, + null); + await identityRepository.AddSessionAsync(session, cancellationToken); + return new IssuedSessionTokens( + GenerateAccessToken(user, profile, session), + refreshToken, + session.Id, + profile.Id); + } + + public async Task RotateSessionAsync( + AuthenticatedSession authenticatedSession, + UserProfile profile, + string expectedRefreshToken, + bool reauthenticated, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var refreshToken = GenerateRefreshToken(); + var authenticatedAt = reauthenticated ? now : (DateTimeOffset?)null; + if (!await identityRepository.TryRotateSessionAsync( + authenticatedSession.Session.Id, + HashRefreshToken(expectedRefreshToken), + HashRefreshToken(refreshToken), + profile.Id, + authenticatedAt, + now, + now + RefreshTokenLifetime, + cancellationToken)) + return null; + + var session = authenticatedSession.Session with + { + ActiveProfileId = profile.Id, + RefreshTokenHash = HashRefreshToken(refreshToken), + AuthenticatedAt = authenticatedAt ?? authenticatedSession.Session.AuthenticatedAt, + LastSeenAt = now, + ExpiresAt = now + RefreshTokenLifetime + }; + return new IssuedSessionTokens( + GenerateAccessToken(authenticatedSession.User, profile, session), + refreshToken, + session.Id, + profile.Id); + } + + public static string HashRefreshToken(string refreshToken) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken))); + + private string GenerateAccessToken( + UserAccount user, + UserProfile profile, + UserSession session) + { + var key = Encoding.ASCII.GetBytes(configuration["JwtSecret"]!); + var descriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity( + [ + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.Role, user.Role.ToString()), + new Claim(IdentityClaimTypes.UserId, user.Id.ToString()), + new Claim(IdentityClaimTypes.ProfileId, profile.Id.ToString()), + new Claim(IdentityClaimTypes.SessionId, session.Id.ToString()), + new Claim(IdentityClaimTypes.AuthenticatedAt, + session.AuthenticatedAt.ToUnixTimeSeconds().ToString()), + new Claim("Id", profile.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + ]), + Expires = DateTime.UtcNow.Add(AccessTokenLifetime), + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature) + }; + var handler = new JwtSecurityTokenHandler(); + return handler.WriteToken(handler.CreateToken(descriptor)); + } + + private static string GenerateRefreshToken() => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(48)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private static string? NormalizeDeviceName(string? value) + { + var trimmed = value?.Trim(); + if (string.IsNullOrEmpty(trimmed)) return null; + return trimmed.Length <= 128 ? trimmed : trimmed[..128]; + } +} diff --git a/SecondDimensionWatcherReDive/Controllers/AccountsController.cs b/SecondDimensionWatcherReDive/Controllers/AccountsController.cs new file mode 100644 index 0000000..26f6c34 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/AccountsController.cs @@ -0,0 +1,319 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Auth; +using SecondDimensionWatcherReDive.Framework.Authorization; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Controllers; + +[ApiController] +[Route("api/accounts")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +internal sealed partial class AccountsController( + IIdentityRepository identityRepository, + SessionTokenIssuer tokenIssuer, + IAuthorizationService authorizationService) : ControllerBase +{ + [GeneratedRegex("^[a-z0-9._-]{3,64}$")] + private static partial Regex UsernamePattern(); + + [GeneratedRegex("^[0-9]{4,8}$")] + private static partial Regex PinPattern(); + + [HttpGet("profiles")] + public async Task GetProfiles(CancellationToken cancellationToken) + { + if (!User.TryGetUserId(out var userId)) return Unauthorized(); + var profiles = await identityRepository.GetProfilesAsync(userId, cancellationToken); + return Ok(profiles.Select(AuthController.ToProfileResponse).ToList()); + } + + [HttpPost("profiles")] + [Authorize(Policy = AccessPolicies.ContentWrite)] + public async Task CreateProfile( + [FromBody] External.CreateProfileRequest request, + CancellationToken cancellationToken) + { + if (!User.TryGetUserId(out var userId)) return Unauthorized(); + if (!TryNormalizeProfile(request.Name, request.Avatar, request.Pin, + out var name, out var avatar, out var pinHash)) + return BadRequest(); + var now = DateTimeOffset.UtcNow; + UserProfile profile; + try + { + profile = await identityRepository.AddProfileAsync( + new UserProfile( + Guid.NewGuid(), userId, name, avatar, pinHash, false, now, now), + cancellationToken); + } + catch (IdentityConflictException) + { + return Conflict(); + } + return Ok(AuthController.ToProfileResponse(profile)); + } + + [HttpPatch("profiles/{id:guid}")] + [Authorize(Policy = AccessPolicies.ContentWrite)] + public async Task UpdateProfile( + [FromRoute] Guid id, + [FromBody] External.UpdateProfileRequest request, + CancellationToken cancellationToken) + { + if (!User.TryGetUserId(out var userId)) return Unauthorized(); + if (!User.TryGetProfileId(out var activeProfileId)) return Unauthorized(); + var target = await identityRepository.FindProfileAsync(id, cancellationToken); + if (target is null || target.UserId != userId) return NotFound(); + + var needsStepUp = id != activeProfileId || request.ReplacePin; + if (needsStepUp) + { + var pinVerified = target.PinHash is not null + && VerifyPin(request.CurrentPin, target.PinHash); + var recentlyAuthenticated = (await authorizationService.AuthorizeAsync( + User, resource: null, AccessPolicies.RecentAuthentication)).Succeeded; + if (!pinVerified && !recentlyAuthenticated) return Forbid(); + } + + if (!TryNormalizeProfile( + request.Name, + request.Avatar, + request.ReplacePin ? request.Pin : null, + out var name, + out var avatar, + out var pinHash)) + return BadRequest(); + bool updated; + try + { + updated = await identityRepository.UpdateProfileAsync( + id, + userId, + name, + avatar, + pinHash, + request.ReplacePin, + DateTimeOffset.UtcNow, + cancellationToken); + } + catch (IdentityConflictException) + { + return Conflict(); + } + return updated ? NoContent() : NotFound(); + } + + [HttpPost("profiles/switch")] + public async Task SwitchProfile( + [FromBody] External.SwitchProfileRequest request, + CancellationToken cancellationToken) + { + if (!User.TryGetUserId(out var userId) + || !User.TryGetSessionId(out var sessionId)) + return Unauthorized(); + var profile = await identityRepository.FindProfileAsync( + request.ProfileId, cancellationToken); + if (profile is null || profile.UserId != userId) + return NotFound(); + if (profile.PinHash is not null && !VerifyPin(request.Pin, profile.PinHash)) + return Unauthorized(); + + var authenticated = await identityRepository.GetAuthenticatedSessionAsync( + sessionId, DateTimeOffset.UtcNow, cancellationToken); + if (authenticated is null || authenticated.User.Id != userId) + return Unauthorized(); + var rotated = await tokenIssuer.RotateSessionAsync( + authenticated, + profile, + request.RefreshToken, + reauthenticated: false, + cancellationToken); + return rotated is null + ? Unauthorized() + : Ok(new External.LoginResult( + rotated.AccessToken, + rotated.RefreshToken, + true, + rotated.SessionId, + rotated.ProfileId)); + } + + [HttpGet("sessions")] + public async Task GetOwnSessions(CancellationToken cancellationToken) + { + if (!User.TryGetUserId(out var userId)) return Unauthorized(); + User.TryGetSessionId(out var currentSessionId); + var sessions = await identityRepository.GetSessionsAsync(userId, cancellationToken); + return Ok(sessions.Select(session => ToResponse(session, currentSessionId)).ToList()); + } + + [HttpDelete("sessions/{id:guid}")] + public async Task RevokeOwnSession( + [FromRoute] Guid id, + CancellationToken cancellationToken) + { + if (!User.TryGetUserId(out var userId)) return Unauthorized(); + var revoked = await identityRepository.RevokeSessionAsync( + id, userId, DateTimeOffset.UtcNow, cancellationToken); + return revoked ? NoContent() : NotFound(); + } + + [HttpGet("users")] + [Authorize(Policy = AccessPolicies.Administrator)] + public async Task GetUsers(CancellationToken cancellationToken) + { + var users = await identityRepository.GetUsersAsync(cancellationToken); + return Ok(users.Select(ToResponse).ToList()); + } + + [HttpPost("users")] + [Authorize(Policy = AccessPolicies.RecentAdministrator)] + public async Task CreateUser( + [FromBody] External.CreateUserRequest request, + CancellationToken cancellationToken) + { + var username = request.Username.Trim().ToLowerInvariant(); + if (!UsernamePattern().IsMatch(username) + || string.IsNullOrEmpty(request.Password) + || !TryParseRole(request.Role, out var role) + || !TryNormalizeProfile(request.ProfileName, null, null, + out var profileName, out _, out _)) + return BadRequest(); + if (await identityRepository.FindUserByUsernameAsync(username, cancellationToken) is not null) + return Conflict(); + + var now = DateTimeOffset.UtcNow; + var user = new UserAccount( + Guid.NewGuid(), + username, + BCrypt.Net.BCrypt.HashPassword(request.Password), + role, + false, + now, + now); + var profile = new UserProfile( + Guid.NewGuid(), + user.Id, + profileName, + null, + null, + true, + now, + now); + try + { + return Ok(ToResponse(await identityRepository.CreateUserWithProfileAsync( + user, profile, cancellationToken))); + } + catch (IdentityConflictException) + { + return Conflict(); + } + } + + [HttpPatch("users/{id:guid}")] + [Authorize(Policy = AccessPolicies.RecentAdministrator)] + public async Task UpdateUserAccess( + [FromRoute] Guid id, + [FromBody] External.UpdateUserAccessRequest request, + CancellationToken cancellationToken) + { + if (!TryParseRole(request.Role, out var role)) return BadRequest(); + var result = await identityRepository.UpdateUserAccessAsync( + id, role, request.IsDisabled, DateTimeOffset.UtcNow, cancellationToken); + return result switch + { + UpdateUserAccessResult.Updated => NoContent(), + UpdateUserAccessResult.NotFound => NotFound(), + UpdateUserAccessResult.LastAdministrator => Conflict(new + { + message = "At least one enabled administrator is required." + }), + _ => throw new ArgumentOutOfRangeException(nameof(result), result, null) + }; + } + + [HttpGet("sessions/all")] + [Authorize(Policy = AccessPolicies.Administrator)] + public async Task GetAllSessions(CancellationToken cancellationToken) + { + User.TryGetSessionId(out var currentSessionId); + var sessions = await identityRepository.GetSessionsAsync(null, cancellationToken); + return Ok(sessions.Select(session => ToResponse(session, currentSessionId)).ToList()); + } + + [HttpDelete("sessions/{id:guid}/admin")] + [Authorize(Policy = AccessPolicies.RecentAdministrator)] + public async Task RevokeAnySession( + [FromRoute] Guid id, + CancellationToken cancellationToken) + { + var revoked = await identityRepository.RevokeSessionAsync( + id, null, DateTimeOffset.UtcNow, cancellationToken); + return revoked ? NoContent() : NotFound(); + } + + private static bool TryNormalizeProfile( + string rawName, + string? rawAvatar, + string? rawPin, + out string name, + out string? avatar, + out string? pinHash) + { + name = rawName.Trim(); + avatar = string.IsNullOrWhiteSpace(rawAvatar) ? null : rawAvatar.Trim(); + pinHash = null; + if (name.Length is < 1 or > 64 || avatar?.Length > 512) + return false; + if (rawPin is null) return true; + if (rawPin.Length == 0) return true; + if (!PinPattern().IsMatch(rawPin)) return false; + pinHash = BCrypt.Net.BCrypt.HashPassword(rawPin); + return true; + } + + private static bool VerifyPin(string? pin, string hash) + { + if (pin is null) return false; + try + { + return BCrypt.Net.BCrypt.Verify(pin, hash); + } + catch (BCrypt.Net.SaltParseException) + { + return false; + } + } + + private static bool TryParseRole(string raw, out UserRole role) => + Enum.TryParse(raw, ignoreCase: true, out role) + && Enum.IsDefined(role); + + private static External.UserResponse ToResponse(UserAccountWithProfiles item) => + new(item.User.Id, + item.User.Username, + item.User.Role.ToString(), + item.User.IsDisabled, + item.User.CreatedAt, + item.Profiles.Select(AuthController.ToProfileResponse).ToList()); + + private static External.SessionResponse ToResponse( + UserSessionSummary item, + Guid currentSessionId) => + new(item.Session.Id, + item.Session.UserId, + item.Username, + item.Session.ActiveProfileId, + item.ProfileName, + item.Session.DeviceName, + item.Session.AuthenticatedAt, + item.Session.CreatedAt, + item.Session.LastSeenAt, + item.Session.ExpiresAt, + item.Session.RevokedAt, + item.Session.Id == currentSessionId); +} diff --git a/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs b/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs index 8e3ee5b..53e3ec6 100644 --- a/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs +++ b/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Framework.Authorization; using Microsoft.Extensions.Caching.Distributed; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileDownload; @@ -19,6 +20,7 @@ internal class AnimationInfoController( IDistributedCache distributedCache, IFileDownloadClientProvider fileDownloadClientProvider, IFileMapper fileMapper, + IAuthorizationService authorizationService, IIncidentReporter? incidentReporter = null) : ControllerBase { @@ -68,6 +70,7 @@ public async Task GetDownloadStatus([FromRoute] Guid id, Cancella } [HttpPost("download/{id:guid}")] + [Authorize(Policy = AccessPolicies.ContentWrite)] public async Task StartDownload([FromRoute] Guid id, CancellationToken cancellationToken) { var info = await animationInfoRepository.FindByIdAsync(id, cancellationToken); @@ -129,6 +132,7 @@ await CompensateFailedStartAsync( } [HttpPost("pause/{id:guid}")] + [Authorize(Policy = AccessPolicies.ContentWrite)] public async Task PauseDownload([FromRoute] Guid id, CancellationToken cancellationToken) { var info = await animationInfoRepository.FindByIdAsync(id, cancellationToken); @@ -152,6 +156,7 @@ public async Task PauseDownload([FromRoute] Guid id, Cancellation } [HttpPost("resume/{id:guid}")] + [Authorize(Policy = AccessPolicies.ContentWrite)] public async Task ResumeDownload([FromRoute] Guid id, CancellationToken cancellationToken) { var info = await animationInfoRepository.FindByIdAsync(id, cancellationToken); @@ -175,9 +180,14 @@ public async Task ResumeDownload([FromRoute] Guid id, Cancellatio } [HttpDelete("cancel/{id:guid}")] + [Authorize(Policy = AccessPolicies.ContentWrite)] public async Task CancelDownload([FromRoute] Guid id, [FromQuery] bool removeFile = false, CancellationToken cancellationToken = default) { + if (removeFile && !(await authorizationService.AuthorizeAsync( + User, resource: null, AccessPolicies.RecentAdministrator)).Succeeded) + return Forbid(); + var info = await animationInfoRepository.FindByIdAsync(id, cancellationToken); if (info is null) @@ -298,6 +308,7 @@ private static CancellationTokenSource CreateDownloadSagaTokenSource() => new(TimeSpan.FromSeconds(10)); [HttpPost("{id:guid}/retry-inference")] + [Authorize(Policy = AccessPolicies.Administrator)] public async Task RetryInference([FromRoute] Guid id, CancellationToken cancellationToken) { var info = await animationInfoRepository.FindByIdAsync(id, cancellationToken); @@ -319,6 +330,7 @@ public async Task RetryInference([FromRoute] Guid id, Cancellatio } [HttpPost("{id:guid}/reidentify-files/ai")] + [Authorize(Policy = AccessPolicies.Administrator)] public async Task ReidentifyFilesWithAi( [FromRoute] Guid id, CancellationToken cancellationToken) diff --git a/SecondDimensionWatcherReDive/Controllers/AuthController.cs b/SecondDimensionWatcherReDive/Controllers/AuthController.cs index 217b4f4..f4108e4 100644 --- a/SecondDimensionWatcherReDive/Controllers/AuthController.cs +++ b/SecondDimensionWatcherReDive/Controllers/AuthController.cs @@ -1,151 +1,307 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Distributed; using Microsoft.IdentityModel.Tokens; +using SecondDimensionWatcherReDive.Auth; +using SecondDimensionWatcherReDive.Framework.Authorization; +using SecondDimensionWatcherReDive.Framework.DataRepository; namespace SecondDimensionWatcherReDive.Controllers; [ApiController] [Route("api/[controller]")] -internal partial class AuthController : ControllerBase +internal partial class AuthController( + IConfiguration configuration, + TokenValidationParameters tokenValidationParams, + IIdentityRepository identityRepository, + SessionTokenIssuer tokenIssuer, + ILogger logger) : ControllerBase { - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - private readonly IDistributedCache _distributedCache; + [GeneratedRegex("^[a-z0-9._-]{3,64}$")] + private static partial Regex UsernamePattern(); - private readonly TokenValidationParameters _tokenValidationParams; - - public AuthController(IConfiguration configuration, TokenValidationParameters tokenValidationParams, - IDistributedCache distributedCache, ILogger logger) + [HttpPost("register")] + public async Task Register( + [FromBody] External.LoginData data, + CancellationToken cancellationToken) { - _configuration = configuration; - _tokenValidationParams = tokenValidationParams; - _distributedCache = distributedCache; - _logger = logger; - } + if (await identityRepository.AnyUsersAsync(cancellationToken) + || HasLegacyPassword()) + return Conflict(); + if (!TryNormalizeUsername(data.Username, out var username) + || string.IsNullOrEmpty(data.Password)) + return BadRequest(); - private static string RandomString(int length) - { - const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - return RandomNumberGenerator.GetString(chars, length); + var now = DateTimeOffset.UtcNow; + var user = new UserAccount( + IdentityDefaults.UserId, + username, + BCrypt.Net.BCrypt.HashPassword(data.Password), + UserRole.Admin, + false, + now, + now); + var profile = new UserProfile( + IdentityDefaults.ProfileId, + user.Id, + NormalizeProfileName(data.ProfileName), + null, + null, + true, + now, + now); + try + { + await identityRepository.CreateUserWithProfileAsync(user, profile, cancellationToken); + } + catch (IdentityConflictException) + { + return Conflict(); + } + return Ok(ToResult(await tokenIssuer.CreateSessionAsync( + user, profile, data.DeviceName, cancellationToken))); } - private async Task GenerateJwtTokenAsync() + [HttpPost("login")] + public async Task Login( + [FromBody] External.LoginData data, + CancellationToken cancellationToken) { - var handler = new JwtSecurityTokenHandler(); - var key = Encoding.ASCII.GetBytes(_configuration["JwtSecret"]!); + if (!TryNormalizeUsername(data.Username, out var username)) + return Unauthorized(); - var tokenDescriptor = new SecurityTokenDescriptor - { - Subject = new ClaimsIdentity(new[] - { - new Claim("Id", Guid.Empty.ToString()), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) - }), - Expires = DateTime.UtcNow.AddMinutes(10), - SigningCredentials = - new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) - }; + var user = await identityRepository.FindUserByUsernameAsync(username, cancellationToken); + if (user is null + && string.Equals(username, IdentityDefaults.Username, StringComparison.Ordinal) + && VerifyLegacyPassword(data.Password)) + user = await CreateLegacyAdminAsync(data.Password, cancellationToken); + if (user is null || user.IsDisabled || !await VerifyPasswordAsync( + user, data.Password, cancellationToken)) + return Unauthorized(); + + var profiles = await identityRepository.GetProfilesAsync(user.Id, cancellationToken); + var profile = profiles.FirstOrDefault(candidate => candidate.IsDefault) + ?? profiles.FirstOrDefault(); + if (profile is null) return Unauthorized(); - var token = handler.CreateToken(tokenDescriptor); - var jwtToken = handler.WriteToken(token); + return Ok(ToResult(await tokenIssuer.CreateSessionAsync( + user, profile, data.DeviceName, cancellationToken))); + } - var refreshToken = new External.RefreshToken(RandomString(25) + Guid.NewGuid(), token.Id); + [HttpPost("refresh")] + public async Task Refresh( + [FromBody] External.AuthRequest request, + CancellationToken cancellationToken) + { + var principal = ValidateExpiredAccessToken(request.Token); + if (principal is null + || !principal.TryGetUserId(out var userId) + || !principal.TryGetSessionId(out var sessionId)) + return Unauthorized(new External.LoginResult(null, null, false)); - await _distributedCache.SetStringAsync(refreshToken.Token, - JsonSerializer.Serialize(refreshToken, External.AppJsonSerializerContext.Default.RefreshToken)); + var authenticated = await identityRepository.GetAuthenticatedSessionAsync( + sessionId, DateTimeOffset.UtcNow, cancellationToken); + if (authenticated is null || authenticated.User.Id != userId) + return Unauthorized(new External.LoginResult(null, null, false)); - return new External.LoginResult(jwtToken, refreshToken.Token); + var rotated = await tokenIssuer.RotateSessionAsync( + authenticated, + authenticated.Profile, + request.RefreshToken, + reauthenticated: false, + cancellationToken); + return rotated is null + ? Unauthorized(new External.LoginResult(null, null, false)) + : Ok(ToResult(rotated)); } - [HttpPost("register")] - public async Task Register([FromBody] External.LoginData data) + [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + [HttpPost("reauthenticate")] + public async Task Reauthenticate( + [FromBody] External.ReauthenticateRequest request, + CancellationToken cancellationToken) { - if (!string.IsNullOrWhiteSpace(_configuration["Password:Value"])) - return BadRequest(); + var authenticated = await GetCurrentSessionAsync(cancellationToken); + if (authenticated is null + || !await VerifyPasswordAsync(authenticated.User, request.Password, cancellationToken)) + return Unauthorized(); - var passwordFile = _configuration["PasswordFile"] ?? "password.json"; - await System.IO.File.WriteAllBytesAsync(passwordFile, - JsonSerializer.SerializeToUtf8Bytes( - new External.PasswordConfig(new External.PasswordHash(BCrypt.Net.BCrypt.HashPassword(data.Password))), - External.AppJsonSerializerContext.Default.PasswordConfig)); + var rotated = await tokenIssuer.RotateSessionAsync( + authenticated, + authenticated.Profile, + request.RefreshToken, + reauthenticated: true, + cancellationToken); + return rotated is null ? Unauthorized() : Ok(ToResult(rotated)); + } - return Ok(await GenerateJwtTokenAsync()); + [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + [HttpPost("logout")] + public async Task Logout(CancellationToken cancellationToken) + { + if (!User.TryGetSessionId(out var sessionId)) return Unauthorized(); + await identityRepository.RevokeSessionAsync( + sessionId, + requiredUserId: null, + DateTimeOffset.UtcNow, + cancellationToken); + return NoContent(); } - [HttpPost("login")] - public async Task Login([FromBody] External.LoginData data) + [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + [HttpGet("verify")] + public async Task Verify(CancellationToken cancellationToken) { - var storedValue = _configuration["Password:Value"]; - if (string.IsNullOrWhiteSpace(storedValue)) - return BadRequest(); + var authenticated = await GetCurrentSessionAsync(cancellationToken); + if (authenticated is null) return Unauthorized(); + var profiles = await identityRepository.GetProfilesAsync( + authenticated.User.Id, cancellationToken); + return Ok(new External.AuthStateResponse( + authenticated.User.Id, + authenticated.User.Username, + authenticated.User.Role.ToString(), + authenticated.Session.Id, + authenticated.Profile.Id, + profiles.Select(ToProfileResponse).ToList())); + } - if (!BCrypt.Net.BCrypt.Verify(data.Password, storedValue)) - return BadRequest(); + [HttpGet("allowRegister")] + public async Task CanRegister(CancellationToken cancellationToken) => + Ok(new + { + Allow = !HasLegacyPassword() + && !await identityRepository.AnyUsersAsync(cancellationToken) + }); - return Ok(await GenerateJwtTokenAsync()); + private async Task GetCurrentSessionAsync( + CancellationToken cancellationToken) + { + if (!User.TryGetSessionId(out var sessionId)) return null; + return await identityRepository.GetAuthenticatedSessionAsync( + sessionId, DateTimeOffset.UtcNow, cancellationToken); } - [HttpPost("refresh")] - public async Task Refresh([FromBody] External.AuthRequest request) + private ClaimsPrincipal? ValidateExpiredAccessToken(string token) { - var result = await VerifyAndGenerateTokenAsync(request); - return result.Success ? Ok(result) : BadRequest(result); + try + { + var parameters = tokenValidationParams.Clone(); + parameters.ValidateLifetime = false; + var principal = new JwtSecurityTokenHandler().ValidateToken( + token, parameters, out var validatedToken); + return validatedToken is JwtSecurityToken securityToken + && string.Equals( + securityToken.Header.Alg, + SecurityAlgorithms.HmacSha256, + StringComparison.OrdinalIgnoreCase) + ? principal + : null; + } + catch (Exception exception) + { + LogTokenVerificationFailed(logger, exception); + return null; + } } - private async Task VerifyAndGenerateTokenAsync(External.AuthRequest request) + private async Task CreateLegacyAdminAsync( + string password, + CancellationToken cancellationToken) { + var now = DateTimeOffset.UtcNow; + var user = new UserAccount( + IdentityDefaults.UserId, + IdentityDefaults.Username, + BCrypt.Net.BCrypt.HashPassword(password), + UserRole.Admin, + false, + now, + now); + var profile = new UserProfile( + IdentityDefaults.ProfileId, + user.Id, + IdentityDefaults.ProfileName, + null, + null, + true, + now, + now); try { - var handler = new JwtSecurityTokenHandler(); - var param = _tokenValidationParams.Clone(); - param.ValidateLifetime = false; - var tokenInVerification = - handler.ValidateToken(request.Token, param, out var validatedToken); - + await identityRepository.CreateUserWithProfileAsync(user, profile, cancellationToken); + return user; + } + catch (IdentityConflictException) + { + var existing = await identityRepository.FindUserByUsernameAsync( + IdentityDefaults.Username, cancellationToken); + if (existing is null) throw; + return existing; + } + } - if (validatedToken is JwtSecurityToken securityToken && !securityToken.Header.Alg.Equals( - SecurityAlgorithms.HmacSha256, - StringComparison.InvariantCultureIgnoreCase)) - return new External.LoginResult(null, null, false); + private async Task VerifyPasswordAsync( + UserAccount user, + string password, + CancellationToken cancellationToken) + { + if (user.PasswordHash is not null) + return VerifyHash(password, user.PasswordHash); + if (user.Id != IdentityDefaults.UserId || !VerifyLegacyPassword(password)) + return false; - var storedJson = await _distributedCache.GetStringAsync(request.RefreshToken); - var storedToken = storedJson is null ? null : JsonSerializer.Deserialize(storedJson, External.AppJsonSerializerContext.Default.RefreshToken); - if (storedToken is null) return new External.LoginResult(null, null, false); + return await identityRepository.SetPasswordHashAsync( + user.Id, + BCrypt.Net.BCrypt.HashPassword(password), + DateTimeOffset.UtcNow, + cancellationToken); + } - if (tokenInVerification.FindFirst(c => c.Type == JwtRegisteredClaimNames.Jti)?.Value != storedToken.JwtId) - return new External.LoginResult(null, null, false); + private bool VerifyLegacyPassword(string password) + { + var value = configuration["Password:Value"]; + return !string.IsNullOrWhiteSpace(value) && VerifyHash(password, value); + } - await _distributedCache.RemoveAsync(request.RefreshToken); + private bool HasLegacyPassword() => + !string.IsNullOrWhiteSpace(configuration["Password:Value"]); - return await GenerateJwtTokenAsync(); + private static bool VerifyHash(string password, string hash) + { + try + { + return BCrypt.Net.BCrypt.Verify(password, hash); } - catch (Exception exception) + catch (BCrypt.Net.SaltParseException) { - LogTokenVerificationFailed(_logger, exception); - return new External.LoginResult(null, null, false); + return false; } } - [HttpGet("verify")] - [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] - public IActionResult Verify() + private static bool TryNormalizeUsername(string? value, out string username) { - return Ok(HttpContext.User.Claims.Select(c => new { c.Type, c.Value })); + username = string.IsNullOrWhiteSpace(value) + ? IdentityDefaults.Username + : value.Trim().ToLowerInvariant(); + return UsernamePattern().IsMatch(username); } - [HttpGet("allowRegister")] - public IActionResult CanRegister() + private static string NormalizeProfileName(string? value) { - return Ok(new { Allow = string.IsNullOrWhiteSpace(_configuration["Password:Value"]) }); + var name = value?.Trim(); + if (string.IsNullOrEmpty(name)) return IdentityDefaults.ProfileName; + return name.Length <= 64 ? name : name[..64]; } - [LoggerMessage(Level = LogLevel.Error, Message = "Token verification failed")] - private static partial void LogTokenVerificationFailed(ILogger logger, Exception ex); + private static External.LoginResult ToResult(IssuedSessionTokens tokens) => + new(tokens.AccessToken, tokens.RefreshToken, true, tokens.SessionId, tokens.ProfileId); + + internal static External.AuthProfileResponse ToProfileResponse(UserProfile profile) => + new(profile.Id, profile.Name, profile.Avatar, profile.PinHash is not null, profile.IsDefault); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Token verification failed")] + private static partial void LogTokenVerificationFailed(ILogger logger, Exception exception); } diff --git a/SecondDimensionWatcherReDive/Controllers/Converter.cs b/SecondDimensionWatcherReDive/Controllers/Converter.cs index 742d740..a011b05 100644 --- a/SecondDimensionWatcherReDive/Controllers/Converter.cs +++ b/SecondDimensionWatcherReDive/Controllers/Converter.cs @@ -83,7 +83,15 @@ public static External.SubscriptionAutomationSimulationResult ToExternal( explanation.Message)).ToList())).ToList()); public static External.WebDavTokenSummary ToExternal(this WebDavToken record) => - new(record.Id, record.Username, record.Description, record.CreatedAt); + new(record.Id, + record.UserId, + record.Username, + record.Description, + record.CreatedAt, + record.Scope, + record.VirtualRoot, + record.ExpiresAt, + record.RevokedAt); public static External.SeasonBangumi ToExternal(this SeasonBangumi record) => new(record.Id, diff --git a/SecondDimensionWatcherReDive/Controllers/External/Accounts.cs b/SecondDimensionWatcherReDive/Controllers/External/Accounts.cs new file mode 100644 index 0000000..af01689 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/External/Accounts.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; + +namespace SecondDimensionWatcherReDive.Controllers.External; + +internal sealed record CreateProfileRequest( + [Required] string Name, + string? Avatar, + string? Pin); + +internal sealed record UpdateProfileRequest( + [Required] string Name, + string? Avatar, + string? Pin, + string? CurrentPin = null, + bool ReplacePin = false); + +internal sealed record SwitchProfileRequest( + Guid ProfileId, + string? Pin, + [Required] string RefreshToken); + +internal sealed record SessionResponse( + Guid Id, + Guid UserId, + string Username, + Guid ProfileId, + string ProfileName, + string? DeviceName, + DateTimeOffset AuthenticatedAt, + DateTimeOffset CreatedAt, + DateTimeOffset LastSeenAt, + DateTimeOffset ExpiresAt, + DateTimeOffset? RevokedAt, + bool IsCurrent); + +internal sealed record UserResponse( + Guid Id, + string Username, + string Role, + bool IsDisabled, + DateTimeOffset CreatedAt, + IReadOnlyList Profiles); + +internal sealed record CreateUserRequest( + [Required] string Username, + [Required] string Password, + [Required] string Role, + [Required] string ProfileName); + +internal sealed record UpdateUserAccessRequest( + [Required] string Role, + bool IsDisabled); diff --git a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs index 0f20309..f1b042b 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs @@ -21,7 +21,18 @@ namespace SecondDimensionWatcherReDive.Controllers.External; [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(FileStoreListResult[]))] [JsonSerializable(typeof(FileStoreToken))] -[JsonSerializable(typeof(RefreshToken))] +[JsonSerializable(typeof(ReauthenticateRequest))] +[JsonSerializable(typeof(AuthProfileResponse))] +[JsonSerializable(typeof(AuthStateResponse))] +[JsonSerializable(typeof(CreateProfileRequest))] +[JsonSerializable(typeof(UpdateProfileRequest))] +[JsonSerializable(typeof(SwitchProfileRequest))] +[JsonSerializable(typeof(SessionResponse))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(UserResponse))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(CreateUserRequest))] +[JsonSerializable(typeof(UpdateUserAccessRequest))] [JsonSerializable(typeof(AddFeedRequest))] [JsonSerializable(typeof(UpsertSubscriptionAutomationPolicyRequest))] [JsonSerializable(typeof(SubscriptionAutomationPolicy))] diff --git a/SecondDimensionWatcherReDive/Controllers/External/Auth.cs b/SecondDimensionWatcherReDive/Controllers/External/Auth.cs index c72c585..d6b4e7c 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/Auth.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/Auth.cs @@ -2,14 +2,40 @@ namespace SecondDimensionWatcherReDive.Controllers.External; -internal sealed record LoginData([Required] string Password); +internal sealed record LoginData( + [Required] string Password, + string? Username = null, + string? DeviceName = null, + string? ProfileName = null); -internal sealed record LoginResult(string? Token, string? RefreshToken, bool Success = true); +internal sealed record LoginResult( + string? Token, + string? RefreshToken, + bool Success = true, + Guid? SessionId = null, + Guid? ProfileId = null); internal sealed record AuthRequest([Required] string Token, [Required] string RefreshToken); -internal sealed record RefreshToken(string Token, string JwtId); +internal sealed record ReauthenticateRequest( + [Required] string Password, + [Required] string RefreshToken); internal sealed record PasswordConfig(PasswordHash Password); internal sealed record PasswordHash(string Value); + +internal sealed record AuthProfileResponse( + Guid Id, + string Name, + string? Avatar, + bool HasPin, + bool IsDefault); + +internal sealed record AuthStateResponse( + Guid UserId, + string Username, + string Role, + Guid SessionId, + Guid ProfileId, + IReadOnlyList Profiles); diff --git a/SecondDimensionWatcherReDive/Controllers/External/File.cs b/SecondDimensionWatcherReDive/Controllers/External/File.cs index e9a0e14..40fbd0a 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/File.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/File.cs @@ -6,6 +6,12 @@ internal sealed record FileLinkResultResponse(string Url); internal sealed record FileLinkResultRequest([Required] Guid Id, string Path); -internal sealed record FileStoreToken(string Path, string FileStore); +internal sealed record FileStoreToken( + string Path, + string FileStore, + Guid SessionId, + Guid UserId, + Guid ProfileId, + string VirtualRoot); internal sealed record FileStoreListResult(string FileName, bool IsDirectory, string? Relative); diff --git a/SecondDimensionWatcherReDive/Controllers/External/WebDavToken.cs b/SecondDimensionWatcherReDive/Controllers/External/WebDavToken.cs index c2fb900..5282c6e 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/WebDavToken.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/WebDavToken.cs @@ -2,15 +2,29 @@ namespace SecondDimensionWatcherReDive.Controllers.External; internal sealed record WebDavTokenSummary( Guid Id, + Guid UserId, string Username, string? Description, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt, + string Scope, + string VirtualRoot, + DateTimeOffset? ExpiresAt, + DateTimeOffset? RevokedAt); -internal sealed record CreateWebDavTokenRequest(string? Username, string? Description); +internal sealed record CreateWebDavTokenRequest( + string? Username, + string? Description, + Guid? UserId = null, + string? VirtualRoot = null, + DateTimeOffset? ExpiresAt = null); internal sealed record CreateWebDavTokenResponse( Guid Id, string Username, string Token, string? Description, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt, + Guid UserId, + string Scope, + string VirtualRoot, + DateTimeOffset ExpiresAt); diff --git a/SecondDimensionWatcherReDive/Controllers/FeedController.cs b/SecondDimensionWatcherReDive/Controllers/FeedController.cs index e651964..8b95aee 100644 --- a/SecondDimensionWatcherReDive/Controllers/FeedController.cs +++ b/SecondDimensionWatcherReDive/Controllers/FeedController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; namespace SecondDimensionWatcherReDive.Controllers; @@ -18,6 +19,7 @@ public async Task GetFeeds(CancellationToken cancellationToken) } [HttpPost] + [Authorize(Policy = AccessPolicies.ContentWrite)] public async Task AddFeed([FromBody] External.AddFeedRequest request, CancellationToken cancellationToken) { @@ -28,6 +30,7 @@ public async Task AddFeed([FromBody] External.AddFeedRequest requ } [HttpDelete("{id:guid}")] + [Authorize(Policy = AccessPolicies.ContentWrite)] public async Task RemoveFeed([FromRoute] Guid id, CancellationToken cancellationToken) { var feed = await feedRepository.FindByIdAsync(id, cancellationToken); diff --git a/SecondDimensionWatcherReDive/Controllers/FileController.cs b/SecondDimensionWatcherReDive/Controllers/FileController.cs index 0d37050..a1dbc1a 100644 --- a/SecondDimensionWatcherReDive/Controllers/FileController.cs +++ b/SecondDimensionWatcherReDive/Controllers/FileController.cs @@ -6,6 +6,8 @@ using Microsoft.AspNetCore.StaticFiles; using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; +using SecondDimensionWatcherReDive.Auth; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileStore; @@ -17,6 +19,7 @@ namespace SecondDimensionWatcherReDive.Controllers; internal partial class FileController( IAnimationInfoRepository animationInfoRepository, IFileExplorer fileExplorer, + IIdentityRepository identityRepository, IDistributedCache distributedCache, IContentTypeProvider contentTypeProvider, ILogger logger) : ControllerBase @@ -32,6 +35,10 @@ private static string GenerateToken(int length) public async Task GetFileLink([FromBody] External.FileLinkResultRequest payload, CancellationToken cancellationToken) { + if (!User.TryGetUserId(out var userId) + || !User.TryGetProfileId(out var profileId) + || !User.TryGetSessionId(out var sessionId)) + return Unauthorized(); LogGenerateLinkRequest(logger, payload.Id, payload.Path); var info = await animationInfoRepository.FindByIdWithAnimationAsync(payload.Id, cancellationToken); @@ -41,12 +48,18 @@ public async Task GetFileLink([FromBody] External.FileLinkResultR return NotFound(); } - var virtualPath = ResolveVirtualPath(info, payload.Path); + if (!TryResolveVirtualPath(info, payload.Path, out var virtualPath)) + return BadRequest(); + var virtualRoot = DevicePathScope.GetVirtualRoot(User); + if (!DevicePathScope.TryMapInternalToPublic( + virtualPath, virtualRoot, out _)) + return Forbid(); LogResolvedTargetPath(logger, virtualPath, "virtual path"); var token = GenerateToken(64); await distributedCache.SetStringAsync(token, - JsonSerializer.Serialize(new External.FileStoreToken(virtualPath, string.Empty), + JsonSerializer.Serialize(new External.FileStoreToken( + virtualPath, string.Empty, sessionId, userId, profileId, virtualRoot), External.AppJsonSerializerContext.Default.FileStoreToken), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1) }, cancellationToken); @@ -68,6 +81,18 @@ public async Task GetFile([FromQuery] [Required] string token, return NotFound(); } + var authenticated = await identityRepository.GetAuthenticatedSessionAsync( + fileStoreToken.SessionId, DateTimeOffset.UtcNow, cancellationToken); + if (authenticated is null + || authenticated.User.Id != fileStoreToken.UserId + || authenticated.Profile.Id != fileStoreToken.ProfileId + || !DevicePathScope.TryMapInternalToPublic( + fileStoreToken.Path, fileStoreToken.VirtualRoot, out _)) + { + LogPlayTokenInvalid(logger); + return NotFound(); + } + var fileName = Path.GetFileName(fileStoreToken.Path); var contentType = contentTypeProvider.TryGetContentType(fileName, out var type) ? type @@ -93,7 +118,11 @@ public async Task GetSubDir([FromQuery] [Required] Guid id, return NotFound(); } - var virtualPath = ResolveVirtualPath(info, relativeDir); + if (!TryResolveVirtualPath(info, relativeDir, out var virtualPath)) + return BadRequest(); + if (!DevicePathScope.TryMapInternalToPublic( + virtualPath, DevicePathScope.GetVirtualRoot(User), out _)) + return Forbid(); LogListPathInfo(logger, virtualPath, true); var tokens = await fileExplorer.EnumerateDirectoryAsync( @@ -109,12 +138,30 @@ public async Task GetSubDir([FromQuery] [Required] Guid id, return Ok(results); } - private static string ResolveVirtualPath(AnimationInfo info, string? relative) + private static bool TryResolveVirtualPath( + AnimationInfo info, + string? relative, + out string virtualPath) { var root = GetAnimationVirtualRoot(info); - if (string.IsNullOrWhiteSpace(relative)) return root; + if (string.IsNullOrWhiteSpace(relative)) + { + virtualPath = root; + return true; + } + var trimmed = relative.Trim('/'); - return string.IsNullOrEmpty(trimmed) ? root : $"{root}/{trimmed}"; + if (trimmed.Length > 2048 + || trimmed.Contains('\\') + || trimmed.Any(char.IsControl) + || trimmed.Split('/').Any(segment => segment.Length == 0 || segment is "." or "..")) + { + virtualPath = string.Empty; + return false; + } + + virtualPath = string.IsNullOrEmpty(trimmed) ? root : $"{root}/{trimmed}"; + return true; } private static string GetAnimationVirtualRoot(AnimationInfo info) diff --git a/SecondDimensionWatcherReDive/Controllers/IncidentsController.cs b/SecondDimensionWatcherReDive/Controllers/IncidentsController.cs index fe2f785..4e62cc6 100644 --- a/SecondDimensionWatcherReDive/Controllers/IncidentsController.cs +++ b/SecondDimensionWatcherReDive/Controllers/IncidentsController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Utils.Incidents; @@ -9,6 +10,7 @@ namespace SecondDimensionWatcherReDive.Controllers; [ApiController] [Route("api/incidents")] [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +[Authorize(Policy = AccessPolicies.Administrator)] internal sealed class IncidentsController( IIncidentRepository incidentRepository, IIncidentRetryService retryService) : ControllerBase diff --git a/SecondDimensionWatcherReDive/Controllers/MediaLibraryController.cs b/SecondDimensionWatcherReDive/Controllers/MediaLibraryController.cs index 17e851b..2c54668 100644 --- a/SecondDimensionWatcherReDive/Controllers/MediaLibraryController.cs +++ b/SecondDimensionWatcherReDive/Controllers/MediaLibraryController.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Npgsql; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Services; using SecondDimensionWatcherReDive.Utils.FileStore; @@ -13,6 +14,7 @@ namespace SecondDimensionWatcherReDive.Controllers; [ApiController] [Route("api/media-library/sources")] [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +[Authorize(Policy = AccessPolicies.Administrator)] internal sealed class MediaLibraryController( IMediaLibrarySourceRepository repository, IMediaLibraryScanQueue scanQueue, diff --git a/SecondDimensionWatcherReDive/Controllers/MetadataReviewController.cs b/SecondDimensionWatcherReDive/Controllers/MetadataReviewController.cs index 0820151..b107ba1 100644 --- a/SecondDimensionWatcherReDive/Controllers/MetadataReviewController.cs +++ b/SecondDimensionWatcherReDive/Controllers/MetadataReviewController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Utils.MetadataReview; using External = SecondDimensionWatcherReDive.Controllers.External; @@ -10,6 +11,7 @@ namespace SecondDimensionWatcherReDive.Controllers; [ApiController] [Route("api/metadata-review")] [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +[Authorize(Policy = AccessPolicies.Administrator)] internal sealed class MetadataReviewController( IMetadataReviewRepository metadataReviewRepository, IMetadataReviewService metadataReviewService) : ControllerBase diff --git a/SecondDimensionWatcherReDive/Controllers/PlaybackController.cs b/SecondDimensionWatcherReDive/Controllers/PlaybackController.cs index 2d4e5f3..1c9c741 100644 --- a/SecondDimensionWatcherReDive/Controllers/PlaybackController.cs +++ b/SecondDimensionWatcherReDive/Controllers/PlaybackController.cs @@ -1,9 +1,9 @@ using System.ComponentModel.DataAnnotations; -using System.Security.Claims; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; using DataAnimationInfo = SecondDimensionWatcherReDive.Framework.DataRepository.AnimationInfo; @@ -36,7 +36,7 @@ public async Task ContinueWatching( [FromQuery, Range(1, MaxContinueLimit)] int limit = DefaultContinueLimit, CancellationToken cancellationToken = default) { - if (!TryGetUserId(out var userId)) return Unauthorized(); + if (!User.TryGetProfileId(out var userId)) return Unauthorized(); var items = await playbackRepository.GetContinueWatchingAsync(userId, limit, cancellationToken); var response = items @@ -52,7 +52,7 @@ public async Task GetStates( [FromQuery] Guid animationInfoId, CancellationToken cancellationToken) { - if (!TryGetUserId(out var userId)) return Unauthorized(); + if (!User.TryGetProfileId(out var userId)) return Unauthorized(); if (animationInfoId == Guid.Empty) return BadRequest(); var info = await animationInfoRepository.FindByIdWithAnimationAsync(animationInfoId, cancellationToken); @@ -86,7 +86,7 @@ public async Task GetContext( [FromQuery, Required] string? path, CancellationToken cancellationToken) { - if (!TryGetUserId(out var userId)) return Unauthorized(); + if (!User.TryGetProfileId(out var userId)) return Unauthorized(); var resolution = await ResolveVideoAsync(animationInfoId, path, cancellationToken); if (resolution.Status is ResolutionStatus.Invalid) return BadRequest(); if (resolution.Status is ResolutionStatus.Missing) return NotFound(); @@ -111,11 +111,12 @@ public async Task GetContext( } [HttpPut("progress")] + [Authorize(Policy = AccessPolicies.PlaybackWrite)] public async Task UpdateProgress( [FromBody] External.PlaybackProgressRequest request, CancellationToken cancellationToken) { - if (!TryGetUserId(out var userId)) return Unauthorized(); + if (!User.TryGetProfileId(out var userId)) return Unauthorized(); if (!double.IsFinite(request.PositionSeconds) || !double.IsFinite(request.DurationSeconds) || request.PositionSeconds < 0 @@ -152,11 +153,12 @@ public async Task UpdateProgress( } [HttpPut("watched")] + [Authorize(Policy = AccessPolicies.PlaybackWrite)] public async Task SetWatched( [FromBody] External.PlaybackWatchedRequest request, CancellationToken cancellationToken) { - if (!TryGetUserId(out var userId)) return Unauthorized(); + if (!User.TryGetProfileId(out var userId)) return Unauthorized(); var resolution = await ResolveVideoAsync(request.AnimationInfoId, request.Path, cancellationToken); if (resolution.Status is ResolutionStatus.Invalid) return BadRequest(); @@ -183,17 +185,18 @@ public async Task SetWatched( [HttpGet("preferences")] public async Task GetPreferences(CancellationToken cancellationToken) { - if (!TryGetUserId(out var userId)) return Unauthorized(); + if (!User.TryGetProfileId(out var userId)) return Unauthorized(); var preferences = await playbackRepository.GetPreferencesAsync(userId, cancellationToken); return Ok(ToPreferencesResponse(preferences)); } [HttpPut("preferences")] + [Authorize(Policy = AccessPolicies.PlaybackWrite)] public async Task UpdatePreferences( [FromBody] External.PlaybackPreferencesRequest request, CancellationToken cancellationToken) { - if (!TryGetUserId(out var userId)) return Unauthorized(); + if (!User.TryGetProfileId(out var userId)) return Unauthorized(); var preferences = new PlaybackPreferences( userId, @@ -355,14 +358,6 @@ private static External.PlaybackPreferencesResponse ToPreferencesResponse(Playba preferences.AutoPlayNext, preferences.UpdatedAt == DateTimeOffset.UnixEpoch ? null : preferences.UpdatedAt); - private bool TryGetUserId(out Guid userId) - { - var raw = User.FindFirstValue("Id") - ?? User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? User.FindFirstValue("sub"); - return Guid.TryParse(raw, out userId); - } - private static bool TryNormalizeRelativePath(string? raw, out string normalized) { normalized = string.Empty; diff --git a/SecondDimensionWatcherReDive/Controllers/SeasonController.cs b/SecondDimensionWatcherReDive/Controllers/SeasonController.cs index 1fa0cb1..ee4eb50 100644 --- a/SecondDimensionWatcherReDive/Controllers/SeasonController.cs +++ b/SecondDimensionWatcherReDive/Controllers/SeasonController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.Tasks; using SecondDimensionWatcherReDive.Utils.Scraper; @@ -106,6 +107,7 @@ await bangumiSubgroupRepository.AddAsync(new BangumiSubgroup( /// Manually refresh the season anime list. [HttpPost("refresh")] + [Authorize(Policy = AccessPolicies.Administrator)] public async Task Refresh(CancellationToken cancellationToken) { // Rate limit: reject if last scrape < 10 minutes ago @@ -124,6 +126,7 @@ public async Task Refresh(CancellationToken cancellationToken) /// Subscribe to a bangumi by creating a Feed record. [HttpPost("subscribe")] + [Authorize(Policy = AccessPolicies.ContentWrite)] public async Task Subscribe([FromBody] External.SubscribeRequest request, CancellationToken cancellationToken) { diff --git a/SecondDimensionWatcherReDive/Controllers/SettingsController.cs b/SecondDimensionWatcherReDive/Controllers/SettingsController.cs index 5bab853..5e79d78 100644 --- a/SecondDimensionWatcherReDive/Controllers/SettingsController.cs +++ b/SecondDimensionWatcherReDive/Controllers/SettingsController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using SecondDimensionWatcherReDive.Configuration; using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Framework.Authorization; namespace SecondDimensionWatcherReDive.Controllers; @@ -13,6 +14,7 @@ namespace SecondDimensionWatcherReDive.Controllers; internal sealed class SettingsController(IRuntimeSettingsService settingsService) : ControllerBase { [HttpGet] + [Authorize(Policy = AccessPolicies.Administrator)] public async Task> GetSettingsAsync( CancellationToken cancellationToken) { @@ -21,6 +23,7 @@ public async Task> GetSettingsAsync( } [HttpPatch] + [Authorize(Policy = AccessPolicies.RecentAdministrator)] public async Task PatchSettingsAsync( [FromBody] PatchApplicationSettingsRequest request, CancellationToken cancellationToken) diff --git a/SecondDimensionWatcherReDive/Controllers/SubscriptionPoliciesController.cs b/SecondDimensionWatcherReDive/Controllers/SubscriptionPoliciesController.cs index cf1a204..2dbc1c1 100644 --- a/SecondDimensionWatcherReDive/Controllers/SubscriptionPoliciesController.cs +++ b/SecondDimensionWatcherReDive/Controllers/SubscriptionPoliciesController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.Feed; @@ -36,6 +37,7 @@ public async Task GetPolicy( } [HttpPut("{feedId:guid}")] + [Authorize(Policy = AccessPolicies.ContentWrite)] public async Task UpsertPolicy( [FromRoute] Guid feedId, [FromBody] External.UpsertSubscriptionAutomationPolicyRequest request, @@ -73,6 +75,7 @@ public async Task SimulatePolicy( } [HttpDelete("{feedId:guid}")] + [Authorize(Policy = AccessPolicies.ContentWrite)] public async Task DeletePolicy( [FromRoute] Guid feedId, CancellationToken cancellationToken) diff --git a/SecondDimensionWatcherReDive/Controllers/TasksController.cs b/SecondDimensionWatcherReDive/Controllers/TasksController.cs index 50dd947..d0a789c 100644 --- a/SecondDimensionWatcherReDive/Controllers/TasksController.cs +++ b/SecondDimensionWatcherReDive/Controllers/TasksController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.Tasks; namespace SecondDimensionWatcherReDive.Controllers; @@ -24,6 +25,7 @@ public IActionResult GetTasks() } [HttpPost("{id}/run")] + [Authorize(Policy = AccessPolicies.Administrator)] public IActionResult RunTask([FromRoute] string id) { var task = scheduledTasks.FirstOrDefault(t => diff --git a/SecondDimensionWatcherReDive/Controllers/VfsController.cs b/SecondDimensionWatcherReDive/Controllers/VfsController.cs index bed4548..40ab1f9 100644 --- a/SecondDimensionWatcherReDive/Controllers/VfsController.cs +++ b/SecondDimensionWatcherReDive/Controllers/VfsController.cs @@ -22,12 +22,13 @@ internal sealed partial class VfsController( [HttpGet("stat")] public async Task Stat([FromQuery] string? path, CancellationToken cancellationToken) { - if (!TryNormalize(path, out var virtualPath)) return BadRequest(); + if (!TryGetScopedPaths(path, out var publicPath, out var internalPath)) + return BadRequest(); - var resource = await ResolveAsync(virtualPath, cancellationToken); + var resource = await ResolveAsync(publicPath, internalPath, cancellationToken); if (resource is null) { - LogResourceMissing(logger, virtualPath); + LogResourceMissing(logger, publicPath); return NotFound(); } @@ -38,25 +39,26 @@ public async Task Stat([FromQuery] string? path, CancellationToke [HttpGet("list")] public async Task List([FromQuery] string? path, CancellationToken cancellationToken) { - if (!TryNormalize(path, out var virtualPath)) return BadRequest(); + if (!TryGetScopedPaths(path, out var publicPath, out var internalPath)) + return BadRequest(); - var resource = await ResolveAsync(virtualPath, cancellationToken); + var resource = await ResolveAsync(publicPath, internalPath, cancellationToken); if (resource is null) { - LogResourceMissing(logger, virtualPath); + LogResourceMissing(logger, publicPath); return NotFound(); } if (!resource.IsDirectory) { - LogListOnFile(logger, virtualPath); + LogListOnFile(logger, publicPath); return BadRequest(); } - var directoryPath = EnsureTrailingSlash(resource.VirtualPath); - var directoryName = resource.VirtualPath == "/" + var directoryPath = EnsureTrailingSlash(resource.InternalPath); + var directoryName = resource.PublicPath == "/" ? string.Empty - : Path.GetFileName(resource.VirtualPath.TrimEnd('/')); + : Path.GetFileName(resource.PublicPath.TrimEnd('/')); var children = await fileExplorer.EnumerateDirectoryAsync( new DirectoryToken(directoryPath, directoryName), cancellationToken); @@ -73,17 +75,18 @@ public async Task List([FromQuery] string? path, CancellationToke [HttpGet("read")] public async Task Read([FromQuery] string? path, CancellationToken cancellationToken) { - if (!TryNormalize(path, out var virtualPath)) return BadRequest(); + if (!TryGetScopedPaths(path, out var publicPath, out var internalPath)) + return BadRequest(); - var resource = await ResolveAsync(virtualPath, cancellationToken); + var resource = await ResolveAsync(publicPath, internalPath, cancellationToken); if (resource is null || resource.IsDirectory || resource.Mapping is null) { - LogResourceMissing(logger, virtualPath); + LogResourceMissing(logger, publicPath); return NotFound(); } var mapping = resource.Mapping; - var fileName = Path.GetFileName(mapping.VirtualPath); + var fileName = Path.GetFileName(resource.PublicPath); var contentType = contentTypeProvider.TryGetContentType(fileName, out var ct) ? ct : "application/octet-stream"; @@ -95,9 +98,9 @@ public async Task Read([FromQuery] string? path, CancellationToke private async Task BuildEntryAsync(ResolvedResource resource, CancellationToken cancellationToken) { - var name = resource.VirtualPath == "/" + var name = resource.PublicPath == "/" ? string.Empty - : Path.GetFileName(resource.VirtualPath.TrimEnd('/')); + : Path.GetFileName(resource.PublicPath.TrimEnd('/')); if (resource.IsDirectory || resource.Mapping is null) return new External.VfsEntry(name, IsDirectory: true, Size: null, LastModifiedUtc: null); @@ -137,53 +140,46 @@ public async Task Read([FromQuery] string? path, CancellationToke } } - private async Task ResolveAsync(string virtualPath, CancellationToken cancellationToken) + private async Task ResolveAsync( + string publicPath, + string internalPath, + CancellationToken cancellationToken) { - if (virtualPath == "/") return new ResolvedResource("/", IsDirectory: true, null); + if (internalPath == "/") + return new ResolvedResource(publicPath, internalPath, IsDirectory: true, null); - var trimmed = virtualPath.TrimEnd('/'); - if (trimmed.Length == 0) return new ResolvedResource("/", IsDirectory: true, null); + var trimmed = internalPath.TrimEnd('/'); + if (trimmed.Length == 0) + return new ResolvedResource(publicPath, "/", IsDirectory: true, null); var mapping = await fileMappingRepository.FindByVirtualPathAsync(trimmed, cancellationToken); - if (mapping is not null) return new ResolvedResource(trimmed, IsDirectory: false, mapping); + if (mapping is not null) + return new ResolvedResource(publicPath, trimmed, IsDirectory: false, mapping); var prefix = trimmed + "/"; var children = await fileMappingRepository.GetByVirtualPathPrefixAsync(prefix, cancellationToken); - return children.Count > 0 ? new ResolvedResource(trimmed, IsDirectory: true, null) : null; + return children.Count > 0 + ? new ResolvedResource(publicPath, trimmed, IsDirectory: true, null) + : null; } - private static bool TryNormalize(string? raw, out string normalized) - { - if (string.IsNullOrEmpty(raw)) - { - normalized = "/"; - return true; - } - - if (!raw.StartsWith('/')) - { - normalized = string.Empty; - return false; - } - - // Reject path traversal segments. We never expect them in legitimate virtual paths. - foreach (var segment in raw.Split('/', StringSplitOptions.RemoveEmptyEntries)) - { - if (segment == "." || segment == "..") - { - normalized = string.Empty; - return false; - } - } - - var trimmed = raw.TrimEnd('/'); - normalized = trimmed.Length == 0 ? "/" : trimmed; - return true; - } + private bool TryGetScopedPaths( + string? raw, + out string publicPath, + out string internalPath) => + DevicePathScope.TryMapPublicToInternal( + raw, + DevicePathScope.GetVirtualRoot(User), + out publicPath, + out internalPath); private static string EnsureTrailingSlash(string path) => path.EndsWith('/') ? path : path + "/"; - private sealed record ResolvedResource(string VirtualPath, bool IsDirectory, FileMapping? Mapping); + private sealed record ResolvedResource( + string PublicPath, + string InternalPath, + bool IsDirectory, + FileMapping? Mapping); [LoggerMessage(Level = LogLevel.Debug, Message = "VFS resource not found: {VirtualPath}")] private static partial void LogResourceMissing(ILogger logger, string virtualPath); diff --git a/SecondDimensionWatcherReDive/Controllers/WebDavController.cs b/SecondDimensionWatcherReDive/Controllers/WebDavController.cs index cdc437f..dea443b 100644 --- a/SecondDimensionWatcherReDive/Controllers/WebDavController.cs +++ b/SecondDimensionWatcherReDive/Controllers/WebDavController.cs @@ -43,13 +43,14 @@ public IActionResult Options() [HttpPropFind(RouteTemplate)] public async Task PropFind(string? path, CancellationToken cancellationToken) { - var virtualPath = NormalizeVirtualPath(path); + if (!TryGetScopedPaths(path, out var publicPath, out var internalPath)) + return BadRequest(); var depth = ParseDepth(Request.Headers[WebDavConstants.Headers.Depth].ToString()); - var resource = await ResolveAsync(virtualPath, cancellationToken); + var resource = await ResolveAsync(publicPath, internalPath, cancellationToken); if (resource is null) { - LogResourceMissing(logger, virtualPath); + LogResourceMissing(logger, publicPath); return NotFound(); } @@ -69,16 +70,30 @@ public async Task PropFind(string? path, CancellationToken cancel if (depth == DepthValue.One && resource.IsDirectory) { var children = await fileExplorer.EnumerateDirectoryAsync( - new DirectoryToken(EnsureTrailingSlash(resource.VirtualPath), Path.GetFileName(resource.VirtualPath.TrimEnd('/'))), + new DirectoryToken( + EnsureTrailingSlash(resource.InternalPath), + Path.GetFileName(resource.InternalPath.TrimEnd('/'))), cancellationToken); foreach (var child in children) { + var childInternalPath = child switch + { + FileToken file => file.Path, + DirectoryToken directory => directory.Path, + _ => null + }; + if (childInternalPath is null) continue; + if (!DevicePathScope.TryMapInternalToPublic( + childInternalPath, + DevicePathScope.GetVirtualRoot(User), + out var childPublicPath)) + continue; var childResource = child switch { - FileToken f => new ResolvedResource(f.Path, IsDirectory: false, + FileToken f => new ResolvedResource(childPublicPath, f.Path, IsDirectory: false, await fileMappingRepository.FindByVirtualPathAsync(f.Path, cancellationToken)), - DirectoryToken d => new ResolvedResource(d.Path, IsDirectory: true, null), + DirectoryToken d => new ResolvedResource(childPublicPath, d.Path, IsDirectory: true, null), _ => null }; if (childResource is null) continue; @@ -93,11 +108,12 @@ await fileMappingRepository.FindByVirtualPathAsync(f.Path, cancellationToken)), [HttpHead(RouteTemplate)] public async Task GetFile(string? path, CancellationToken cancellationToken) { - var virtualPath = NormalizeVirtualPath(path); - var resource = await ResolveAsync(virtualPath, cancellationToken); + if (!TryGetScopedPaths(path, out var publicPath, out var internalPath)) + return BadRequest(); + var resource = await ResolveAsync(publicPath, internalPath, cancellationToken); if (resource is null) { - LogResourceMissing(logger, virtualPath); + LogResourceMissing(logger, publicPath); return NotFound(); } @@ -109,7 +125,7 @@ public async Task GetFile(string? path, CancellationToken cancell } var mapping = resource.Mapping!; - var fileName = Path.GetFileName(mapping.VirtualPath); + var fileName = Path.GetFileName(resource.PublicPath); var contentType = ResolveContentType(fileName); var stream = await fileExplorer.OpenReadStreamAsync(new FileToken(mapping.VirtualPath, fileName), cancellationToken); @@ -134,14 +150,14 @@ private async Task BuildResponseAsync(ResolvedResource resource, Pr { var response = new DavResponse { - Href = BuildHref(resource.VirtualPath, resource.IsDirectory) + Href = BuildHref(resource.PublicPath, resource.IsDirectory) }; var prop = new Prop { - DisplayName = resource.VirtualPath == "/" + DisplayName = resource.PublicPath == "/" ? string.Empty - : Path.GetFileName(resource.VirtualPath.TrimEnd('/')) + : Path.GetFileName(resource.PublicPath.TrimEnd('/')) }; if (resource.IsDirectory) @@ -208,19 +224,27 @@ private async Task BuildResponseAsync(ResolvedResource resource, Pr return response; } - private async Task ResolveAsync(string virtualPath, CancellationToken cancellationToken) + private async Task ResolveAsync( + string publicPath, + string internalPath, + CancellationToken cancellationToken) { - if (virtualPath == "/") return new ResolvedResource("/", IsDirectory: true, null); + if (internalPath == "/") + return new ResolvedResource(publicPath, internalPath, IsDirectory: true, null); - var trimmed = virtualPath.TrimEnd('/'); - if (trimmed.Length == 0) return new ResolvedResource("/", IsDirectory: true, null); + var trimmed = internalPath.TrimEnd('/'); + if (trimmed.Length == 0) + return new ResolvedResource(publicPath, "/", IsDirectory: true, null); var mapping = await fileMappingRepository.FindByVirtualPathAsync(trimmed, cancellationToken); - if (mapping is not null) return new ResolvedResource(trimmed, IsDirectory: false, mapping); + if (mapping is not null) + return new ResolvedResource(publicPath, trimmed, IsDirectory: false, mapping); var prefix = trimmed + "/"; var children = await fileMappingRepository.GetByVirtualPathPrefixAsync(prefix, cancellationToken); - return children.Count > 0 ? new ResolvedResource(trimmed, IsDirectory: true, null) : null; + return children.Count > 0 + ? new ResolvedResource(publicPath, trimmed, IsDirectory: true, null) + : null; } private async Task TryReadPropFindRequestAsync(CancellationToken cancellationToken) @@ -246,11 +270,21 @@ private async Task BuildResponseAsync(ResolvedResource resource, Pr private string ResolveContentType(string fileName) => contentTypeProvider.TryGetContentType(fileName, out var ct) ? ct : "application/octet-stream"; - private static string NormalizeVirtualPath(string? routeValue) + private bool TryGetScopedPaths( + string? routeValue, + out string publicPath, + out string internalPath) { - if (string.IsNullOrEmpty(routeValue)) return "/"; - var trimmed = routeValue.Trim('/'); - return trimmed.Length == 0 ? "/" : "/" + trimmed; + var absolutePath = string.IsNullOrEmpty(routeValue) + ? "/" + : routeValue.StartsWith("/", StringComparison.Ordinal) + ? routeValue + : "/" + routeValue; + return DevicePathScope.TryMapPublicToInternal( + absolutePath, + DevicePathScope.GetVirtualRoot(User), + out publicPath, + out internalPath); } private static string EnsureTrailingSlash(string path) => path.EndsWith('/') ? path : path + "/"; @@ -405,7 +439,11 @@ private static void ApplyFilter(Prop prop, PropFilter filter) if ((filter.Keys & PropertyKeys.Executable) == 0) prop.Executable = null; } - private sealed record ResolvedResource(string VirtualPath, bool IsDirectory, FileMapping? Mapping); + private sealed record ResolvedResource( + string PublicPath, + string InternalPath, + bool IsDirectory, + FileMapping? Mapping); private static readonly object QuotaLock = new(); private static (string? Root, long Total, long Available, DateTime FetchedAt) _quotaCache; diff --git a/SecondDimensionWatcherReDive/Controllers/WebDavTokenController.cs b/SecondDimensionWatcherReDive/Controllers/WebDavTokenController.cs index 18c9cd6..4f8f93a 100644 --- a/SecondDimensionWatcherReDive/Controllers/WebDavTokenController.cs +++ b/SecondDimensionWatcherReDive/Controllers/WebDavTokenController.cs @@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Auth; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.DataRepository; namespace SecondDimensionWatcherReDive.Controllers; @@ -10,16 +12,22 @@ namespace SecondDimensionWatcherReDive.Controllers; [ApiController] [Route("api/webdav-tokens")] [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] -internal partial class WebDavTokenController(IWebDavTokenRepository repository) : ControllerBase +internal partial class WebDavTokenController( + IWebDavTokenRepository repository, + IIdentityRepository identityRepository, + IFileMappingRepository fileMappingRepository) : ControllerBase { private const string UsernameAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; private const int GeneratedUsernameLength = 8; private const int TokenByteLength = 32; + private static readonly TimeSpan DefaultLifetime = TimeSpan.FromDays(365); + private static readonly TimeSpan MaximumLifetime = TimeSpan.FromDays(365 * 5); [GeneratedRegex(@"^[A-Za-z0-9._-]{3,32}$")] private static partial Regex UsernamePattern(); [HttpGet] + [Authorize(Policy = AccessPolicies.Administrator)] public async Task ListTokens(CancellationToken cancellationToken) { var records = await repository.GetAllOrderedAsync(cancellationToken); @@ -27,6 +35,7 @@ public async Task ListTokens(CancellationToken cancellationToken) } [HttpPost] + [Authorize(Policy = AccessPolicies.RecentAdministrator)] public async Task CreateToken( [FromBody] External.CreateWebDavTokenRequest request, CancellationToken cancellationToken) @@ -42,10 +51,37 @@ public async Task CreateToken( if (await repository.ExistsByUsernameAsync(username, cancellationToken)) return Conflict(new { error = "Username already exists." }); + if (!User.TryGetUserId(out var currentUserId)) return Unauthorized(); + var userId = request.UserId ?? currentUserId; + var targetUser = await identityRepository.FindUserByIdAsync(userId, cancellationToken); + if (targetUser is null || targetUser.IsDisabled) return BadRequest(); + + if (!DevicePathScope.TryNormalizeAbsolutePath( + request.VirtualRoot, out var virtualRoot)) + return BadRequest(new { error = "VirtualRoot must be an absolute path without traversal segments." }); + if (!await IsDirectoryAsync(virtualRoot, cancellationToken)) + return BadRequest(new { error = "VirtualRoot must identify an existing directory." }); + + var now = DateTimeOffset.UtcNow; + var expiresAt = request.ExpiresAt ?? now + DefaultLifetime; + if (expiresAt <= now || expiresAt > now + MaximumLifetime) + return BadRequest(new { error = "ExpiresAt must be in the future and no more than five years away." }); + var plaintext = GenerateToken(); var hash = BCrypt.Net.BCrypt.HashPassword(plaintext); var description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(); - var record = new WebDavToken(Guid.NewGuid(), username, hash, description, DateTimeOffset.UtcNow); + if (description?.Length > 256) return BadRequest(); + var record = new WebDavToken( + Guid.NewGuid(), + userId, + username, + hash, + description, + now, + "read", + virtualRoot, + expiresAt, + null); await repository.AddAsync(record, cancellationToken); @@ -54,16 +90,35 @@ public async Task CreateToken( record.Username, plaintext, record.Description, - record.CreatedAt)); + record.CreatedAt, + record.UserId, + record.Scope, + record.VirtualRoot, + expiresAt)); } [HttpDelete("{id:guid}")] + [Authorize(Policy = AccessPolicies.RecentAdministrator)] public async Task DeleteToken([FromRoute] Guid id, CancellationToken cancellationToken) { - var removed = await repository.RemoveByIdAsync(id, cancellationToken); + var removed = await repository.RevokeByIdAsync( + id, DateTimeOffset.UtcNow, cancellationToken); return removed ? NoContent() : NotFound(); } + private async Task IsDirectoryAsync( + string virtualRoot, + CancellationToken cancellationToken) + { + if (virtualRoot == "/") return true; + if (await fileMappingRepository.FindByVirtualPathAsync( + virtualRoot, cancellationToken) is not null) + return false; + var children = await fileMappingRepository.GetByVirtualPathPrefixAsync( + virtualRoot + "/", cancellationToken); + return children.Count > 0; + } + private static string GenerateUsername() => "sdw-" + RandomNumberGenerator.GetString(UsernameAlphabet, GeneratedUsernameLength); diff --git a/SecondDimensionWatcherReDive/Migrations/20260829155550_AddHouseholdIdentityAndAccessScopes.Designer.cs b/SecondDimensionWatcherReDive/Migrations/20260829155550_AddHouseholdIdentityAndAccessScopes.Designer.cs new file mode 100644 index 0000000..5614ced --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260829155550_AddHouseholdIdentityAndAccessScopes.Designer.cs @@ -0,0 +1,1205 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SecondDimensionWatcherReDive.Models; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260829155550_AddHouseholdIdentityAndAccessScopes")] + partial class AddHouseholdIdentityAndAccessScopes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Animation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PosterPath") + .HasColumnType("text"); + + b.Property("TmdbId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("Animations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AnimationGroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalDownloadInfo") + .IsRequired() + .HasColumnType("text"); + + b.Property("AiRetryCount") + .HasColumnType("integer"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("AutomationDisposition") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AutomationExplanationJson") + .HasColumnType("text"); + + b.Property("CachedDownloadData") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CurrentMetadataReviewOperationId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadAttemptId") + .HasColumnType("uuid"); + + b.Property("DownloadCancellationId") + .HasColumnType("uuid"); + + b.Property("DownloadEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadType") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("FileStore") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAiProcessed") + .HasColumnType("boolean"); + + b.Property("IsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("IsDownloadTracked") + .HasColumnType("boolean"); + + b.Property("MediaLibraryMissingSince") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaLibrarySourceId") + .HasColumnType("uuid"); + + b.Property("MetadataConfidence") + .HasColumnType("double precision"); + + b.Property("MetadataLastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MetadataStatus") + .HasColumnType("integer"); + + b.Property("PublishTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleaseSizeBytes") + .HasColumnType("bigint"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceFeedId") + .HasColumnType("uuid"); + + b.Property("StateVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("StorePath") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId"); + + b.HasIndex("CurrentMetadataReviewOperationId") + .IsUnique(); + + b.HasIndex("GroupId"); + + b.HasIndex("MediaLibrarySourceId"); + + b.HasIndex("SourceFeedId"); + + b.HasIndex("FileStore", "StorePath") + .IsUnique() + .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); + + b.HasIndex("MetadataStatus", "PublishTime"); + + b.ToTable("AnimationInfo", t => + { + t.HasCheckConstraint("CK_AnimationInfo_MetadataConfidence_Range", "\"MetadataConfidence\" IS NULL OR (\"MetadataConfidence\" >= 0 AND \"MetadataConfidence\" <= 1)"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ApplicationSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ProtectedSecrets") + .HasColumnType("text"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings", t => + { + t.HasCheckConstraint("CK_ApplicationSettings_Revision_Positive", "\"Revision\" > 0"); + + t.HasCheckConstraint("CK_ApplicationSettings_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MikanSubgroupId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SeasonBangumiId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SeasonBangumiId", "MikanSubgroupId") + .IsUnique(); + + b.ToTable("BangumiSubgroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId", "UpdatedAt"); + + b.ToTable("ChatConversations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToolCallId") + .HasColumnType("text"); + + b.Property("ToolCallsJson") + .HasColumnType("text"); + + b.Property("ToolName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Feed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Feeds"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("VirtualPath") + .IsUnique(); + + b.ToTable("FileMappings"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId", "CreatedAt"); + + b.HasIndex("AnimationId", "Pattern") + .IsUnique(); + + b.ToTable("FileNameRegexRules"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Incident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DetectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastRetryError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Fingerprint") + .IsUnique(); + + b.HasIndex("ResolvedAt", "Type", "UpdatedAt"); + + b.ToTable("Incidents"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.LoginSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActiveProfileId") + .HasColumnType("uuid"); + + b.Property("AuthenticatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActiveProfileId"); + + b.HasIndex("UserId", "RevokedAt", "ExpiresAt"); + + b.ToTable("LoginSessions"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MediaLibrarySource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMonitoring") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastImportedCount") + .HasColumnType("integer"); + + b.Property("LastRemovedCount") + .HasColumnType("integer"); + + b.Property("LastScanAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSkippedCount") + .HasColumnType("integer"); + + b.Property("LastUpdatedCount") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("MediaLibrarySources"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "VirtualPath") + .IsUnique(); + + b.ToTable("MetadataReviewMappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AppliedVersion") + .HasColumnType("bigint"); + + b.Property("BaseFileStore") + .HasColumnType("text"); + + b.Property("BaseIsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("BaseStorePath") + .HasColumnType("text"); + + b.Property("BaseVersion") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousAiRetryCount") + .HasColumnType("integer"); + + b.Property("PreviousAnimationId") + .HasColumnType("uuid"); + + b.Property("PreviousConfidence") + .HasColumnType("double precision"); + + b.Property("PreviousCurrentOperationId") + .HasColumnType("uuid"); + + b.Property("PreviousDescription") + .HasColumnType("text"); + + b.Property("PreviousEpisode") + .HasColumnType("integer"); + + b.Property("PreviousGroupId") + .HasColumnType("uuid"); + + b.Property("PreviousIsAiProcessed") + .HasColumnType("boolean"); + + b.Property("PreviousLastError") + .HasColumnType("text"); + + b.Property("PreviousMetadataStatus") + .HasColumnType("integer"); + + b.Property("PreviousReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousSeason") + .HasColumnType("integer"); + + b.Property("ProposedAnimationName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationOriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationPosterPath") + .HasColumnType("text"); + + b.Property("ProposedAnimationTmdbId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedEpisode") + .HasColumnType("integer"); + + b.Property("ProposedGroupName") + .HasColumnType("text"); + + b.Property("ProposedSeason") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("UndoneAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId", "AppliedVersion") + .IsUnique(); + + b.HasIndex("AnimationInfoId", "State"); + + b.HasIndex("State", "ExpiresAt"); + + b.ToTable("MetadataReviewOperations", t => + { + t.HasCheckConstraint("CK_MetadataReviewOperations_Expiry", "\"ExpiresAt\" > \"CreatedAt\""); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MigrationMarker", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("MigrationMarkers"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("AudioLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AutoPlayNext") + .HasColumnType("boolean"); + + b.Property("SubtitleLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SubtitleTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("PlaybackPreferences"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsWatched") + .HasColumnType("boolean"); + + b.Property("PositionSeconds") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("WatchedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("UserId", "AnimationInfoId", "VirtualPath") + .IsUnique(); + + b.HasIndex("UserId", "IsWatched", "UpdatedAt"); + + b.ToTable("PlaybackProgresses", t => + { + t.HasCheckConstraint("CK_PlaybackProgresses_Duration_NonNegative", "\"DurationSeconds\" >= 0"); + + t.HasCheckConstraint("CK_PlaybackProgresses_Position_NonNegative", "\"PositionSeconds\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("MikanId") + .HasColumnType("integer"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MikanId") + .IsUnique(); + + b.ToTable("SeasonBangumis"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.Property("FeedId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("Codecs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection("ExcludedKeywords") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Languages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("MaxSizeBytes") + .HasColumnType("bigint"); + + b.Property("MinSizeBytes") + .HasColumnType("bigint"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection("Resolutions") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("SubtitleGroups") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("FeedId"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("SubscriptionAutomationPolicies"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDisabled") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.UserProfile", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PinHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualRoot") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("WebDavTokens"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", "Animation") + .WithMany() + .HasForeignKey("AnimationId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationGroup", "Group") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.MediaLibrarySource", null) + .WithMany() + .HasForeignKey("MediaLibrarySourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", null) + .WithMany() + .HasForeignKey("SourceFeedId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Animation"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.SeasonBangumi", "SeasonBangumi") + .WithMany("Subgroups") + .HasForeignKey("SeasonBangumiId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SeasonBangumi"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.UserProfile", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ChatConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", null) + .WithMany() + .HasForeignKey("AnimationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.LoginSession", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.UserProfile", "ActiveProfile") + .WithMany() + .HasForeignKey("ActiveProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SecondDimensionWatcherReDive.Models.UserAccount", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActiveProfile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.UserProfile", "Profile") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.PlaybackPreference", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SecondDimensionWatcherReDive.Models.UserProfile", "Profile") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", "Feed") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", "FeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Feed"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.UserProfile", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.UserAccount", "User") + .WithMany("Profiles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.UserAccount", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Navigation("Subgroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.UserAccount", b => + { + b.Navigation("Profiles"); + + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/20260829155550_AddHouseholdIdentityAndAccessScopes.cs b/SecondDimensionWatcherReDive/Migrations/20260829155550_AddHouseholdIdentityAndAccessScopes.cs new file mode 100644 index 0000000..7c2317e --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260829155550_AddHouseholdIdentityAndAccessScopes.cs @@ -0,0 +1,345 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + /// + public partial class AddHouseholdIdentityAndAccessScopes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExpiresAt", + table: "WebDavTokens", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "RevokedAt", + table: "WebDavTokens", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "Scope", + table: "WebDavTokens", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: "read"); + + migrationBuilder.AddColumn( + name: "UserId", + table: "WebDavTokens", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000001")); + + migrationBuilder.AddColumn( + name: "VirtualRoot", + table: "WebDavTokens", + type: "character varying(2048)", + maxLength: 2048, + nullable: false, + defaultValue: "/"); + + migrationBuilder.AddColumn( + name: "ProfileId", + table: "ChatConversations", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Username = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + PasswordHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + Role = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + IsDisabled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Profiles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Avatar = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + PinHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + IsDefault = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Profiles", x => x.Id); + table.ForeignKey( + name: "FK_Profiles_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "LoginSessions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ActiveProfileId = table.Column(type: "uuid", nullable: false), + RefreshTokenHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + DeviceName = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + AuthenticatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastSeenAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_LoginSessions", x => x.Id); + table.ForeignKey( + name: "FK_LoginSessions_Profiles_ActiveProfileId", + column: x => x.ActiveProfileId, + principalTable: "Profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_LoginSessions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + // Existing installations authenticated a single household and stored playback + // rows under Guid.Empty. Materialize that household only when legacy data exists; + // a truly fresh database must remain eligible for first-user registration. + migrationBuilder.Sql( + """ + INSERT INTO "Users" + ("Id", "Username", "PasswordHash", "Role", "IsDisabled", "CreatedAt", "UpdatedAt") + SELECT + '00000000-0000-0000-0000-000000000001', + 'admin', + NULL, + 'Admin', + FALSE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + WHERE EXISTS (SELECT 1 FROM "PlaybackProgresses") + OR EXISTS (SELECT 1 FROM "PlaybackPreferences") + OR EXISTS (SELECT 1 FROM "ChatConversations") + OR EXISTS (SELECT 1 FROM "WebDavTokens"); + + INSERT INTO "Profiles" + ("Id", "UserId", "Name", "Avatar", "PinHash", "IsDefault", "CreatedAt", "UpdatedAt") + SELECT + '00000000-0000-0000-0000-000000000000', + '00000000-0000-0000-0000-000000000001', + 'Home', + NULL, + NULL, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + WHERE EXISTS ( + SELECT 1 FROM "Users" + WHERE "Id" = '00000000-0000-0000-0000-000000000001'); + + UPDATE "PlaybackProgresses" + SET "UserId" = '00000000-0000-0000-0000-000000000000'; + + UPDATE "PlaybackPreferences" + SET "UserId" = '00000000-0000-0000-0000-000000000000'; + """); + + migrationBuilder.CreateIndex( + name: "IX_WebDavTokens_UserId", + table: "WebDavTokens", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_ChatConversations_ProfileId_UpdatedAt", + table: "ChatConversations", + columns: new[] { "ProfileId", "UpdatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_LoginSessions_ActiveProfileId", + table: "LoginSessions", + column: "ActiveProfileId"); + + migrationBuilder.CreateIndex( + name: "IX_LoginSessions_UserId_RevokedAt_ExpiresAt", + table: "LoginSessions", + columns: new[] { "UserId", "RevokedAt", "ExpiresAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Profiles_UserId_Name", + table: "Profiles", + columns: new[] { "UserId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_ChatConversations_Profiles_ProfileId", + table: "ChatConversations", + column: "ProfileId", + principalTable: "Profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_PlaybackPreferences_Profiles_UserId", + table: "PlaybackPreferences", + column: "UserId", + principalTable: "Profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_PlaybackProgresses_Profiles_UserId", + table: "PlaybackProgresses", + column: "UserId", + principalTable: "Profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_WebDavTokens_Users_UserId", + table: "WebDavTokens", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // The previous schema can represent only the untouched legacy household. Refuse + // downgrade before dropping any column when doing so would merge profile history, + // lose account credentials, widen a device root, revive a revoked token, or remove + // an expiry. PostgreSQL runs the migration transactionally, so this leaves the + // current schema and all data intact. + migrationBuilder.Sql( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM "Users" + WHERE "Id" <> '00000000-0000-0000-0000-000000000001' + OR "Username" <> 'admin' + OR "PasswordHash" IS NOT NULL + OR "Role" <> 'Admin' + OR "IsDisabled") + OR EXISTS ( + SELECT 1 FROM "Profiles" + WHERE "Id" <> '00000000-0000-0000-0000-000000000000' + OR "UserId" <> '00000000-0000-0000-0000-000000000001' + OR "Name" <> 'Home' + OR "Avatar" IS NOT NULL + OR "PinHash" IS NOT NULL + OR NOT "IsDefault") + OR EXISTS ( + SELECT 1 FROM "PlaybackProgresses" + WHERE "UserId" <> '00000000-0000-0000-0000-000000000000') + OR EXISTS ( + SELECT 1 FROM "PlaybackPreferences" + WHERE "UserId" <> '00000000-0000-0000-0000-000000000000') + OR EXISTS ( + SELECT 1 FROM "ChatConversations" + WHERE "ProfileId" <> '00000000-0000-0000-0000-000000000000') + OR EXISTS ( + SELECT 1 FROM "WebDavTokens" + WHERE "UserId" <> '00000000-0000-0000-0000-000000000001' + OR "Scope" <> 'read' + OR "VirtualRoot" <> '/' + OR "ExpiresAt" IS NOT NULL + OR "RevokedAt" IS NOT NULL) + THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'Cannot downgrade household identity safely: the old schema cannot represent current users, profiles, history, or device-token restrictions.'; + END IF; + END $$; + """); + + migrationBuilder.DropForeignKey( + name: "FK_ChatConversations_Profiles_ProfileId", + table: "ChatConversations"); + + migrationBuilder.DropForeignKey( + name: "FK_PlaybackPreferences_Profiles_UserId", + table: "PlaybackPreferences"); + + migrationBuilder.DropForeignKey( + name: "FK_PlaybackProgresses_Profiles_UserId", + table: "PlaybackProgresses"); + + migrationBuilder.DropForeignKey( + name: "FK_WebDavTokens_Users_UserId", + table: "WebDavTokens"); + + migrationBuilder.DropTable( + name: "LoginSessions"); + + migrationBuilder.DropTable( + name: "Profiles"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropIndex( + name: "IX_WebDavTokens_UserId", + table: "WebDavTokens"); + + migrationBuilder.DropIndex( + name: "IX_ChatConversations_ProfileId_UpdatedAt", + table: "ChatConversations"); + + migrationBuilder.DropColumn( + name: "ExpiresAt", + table: "WebDavTokens"); + + migrationBuilder.DropColumn( + name: "RevokedAt", + table: "WebDavTokens"); + + migrationBuilder.DropColumn( + name: "Scope", + table: "WebDavTokens"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "WebDavTokens"); + + migrationBuilder.DropColumn( + name: "VirtualRoot", + table: "WebDavTokens"); + + migrationBuilder.DropColumn( + name: "ProfileId", + table: "ChatConversations"); + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs index 8126b9e..756c4cc 100644 --- a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs +++ b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs @@ -25,7 +25,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Animation", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Name") @@ -273,6 +272,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("ProfileId") + .HasColumnType("uuid"); + b.Property("Title") .HasColumnType("text"); @@ -281,6 +283,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("ProfileId", "UpdatedAt"); + b.ToTable("ChatConversations"); }); @@ -465,6 +469,51 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Incidents"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.LoginSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActiveProfileId") + .HasColumnType("uuid"); + + b.Property("AuthenticatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActiveProfileId"); + + b.HasIndex("UserId", "RevokedAt", "ExpiresAt"); + + b.ToTable("LoginSessions"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MediaLibrarySource", b => { b.Property("Id") @@ -674,7 +723,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => { b.Property("UserId") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("AudioLanguage") @@ -832,6 +880,81 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SubscriptionAutomationPolicies"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDisabled") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.UserProfile", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PinHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Profiles"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => { b.Property("Id") @@ -844,16 +967,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Description") .HasColumnType("text"); + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + b.Property("TokenHash") .IsRequired() .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("uuid"); + b.Property("Username") .IsRequired() .HasColumnType("text"); + b.Property("VirtualRoot") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + b.HasKey("Id"); + b.HasIndex("UserId"); + b.HasIndex("Username") .IsUnique(); @@ -896,6 +1040,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("SeasonBangumi"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.UserProfile", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => { b.HasOne("SecondDimensionWatcherReDive.Models.ChatConversation", "Conversation") @@ -916,6 +1071,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.LoginSession", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.UserProfile", "ActiveProfile") + .WithMany() + .HasForeignKey("ActiveProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SecondDimensionWatcherReDive.Models.UserAccount", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActiveProfile"); + + b.Navigation("User"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => { b.HasOne("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", "Operation") @@ -938,6 +1112,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("AnimationInfo"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.UserProfile", "Profile") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.PlaybackPreference", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => { b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") @@ -946,7 +1131,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("SecondDimensionWatcherReDive.Models.UserProfile", "Profile") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.Navigation("AnimationInfo"); + + b.Navigation("Profile"); }); modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => @@ -960,6 +1153,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Feed"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.UserProfile", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.UserAccount", "User") + .WithMany("Profiles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.UserAccount", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => { b.Navigation("Messages"); @@ -974,6 +1189,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Subgroups"); }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.UserAccount", b => + { + b.Navigation("Profiles"); + + b.Navigation("Sessions"); + }); #pragma warning restore 612, 618 } } diff --git a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs index 59764ac..178422a 100644 --- a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs +++ b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs @@ -33,9 +33,78 @@ public ApplicationContext(DbContextOptions options) public DbSet PlaybackPreferences { get; set; } public DbSet MediaLibrarySources { get; set; } public DbSet ApplicationSettings { get; set; } + public DbSet Users { get; set; } + public DbSet Profiles { get; set; } + public DbSet LoginSessions { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity() + .HasIndex(user => user.Username) + .IsUnique(); + + modelBuilder.Entity() + .Property(user => user.Username) + .HasMaxLength(64); + + modelBuilder.Entity() + .Property(user => user.PasswordHash) + .HasMaxLength(128); + + modelBuilder.Entity() + .Property(user => user.Role) + .HasConversion() + .HasMaxLength(16); + + modelBuilder.Entity() + .Property(profile => profile.Id) + .ValueGeneratedNever(); + + modelBuilder.Entity() + .Property(profile => profile.Name) + .HasMaxLength(64); + + modelBuilder.Entity() + .Property(profile => profile.Avatar) + .HasMaxLength(512); + + modelBuilder.Entity() + .Property(profile => profile.PinHash) + .HasMaxLength(128); + + modelBuilder.Entity() + .HasIndex(profile => new { profile.UserId, profile.Name }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(profile => profile.User) + .WithMany(user => user.Profiles) + .HasForeignKey(profile => profile.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .Property(session => session.RefreshTokenHash) + .HasMaxLength(64); + + modelBuilder.Entity() + .Property(session => session.DeviceName) + .HasMaxLength(128); + + modelBuilder.Entity() + .HasIndex(session => new { session.UserId, session.RevokedAt, session.ExpiresAt }); + + modelBuilder.Entity() + .HasOne(session => session.User) + .WithMany(user => user.Sessions) + .HasForeignKey(session => session.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(session => session.ActiveProfile) + .WithMany() + .HasForeignKey(session => session.ActiveProfileId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() .Property(settings => settings.Id) .ValueGeneratedNever(); @@ -171,6 +240,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasIndex(t => t.Username) .IsUnique(); + modelBuilder.Entity() + .Property(token => token.Scope) + .HasMaxLength(32); + + modelBuilder.Entity() + .Property(token => token.VirtualRoot) + .HasMaxLength(2048); + + modelBuilder.Entity() + .HasOne(token => token.User) + .WithMany() + .HasForeignKey(token => token.UserId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() .HasIndex(progress => new { @@ -193,6 +276,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(progress => progress.AnimationInfoId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(progress => progress.Profile) + .WithMany() + .HasForeignKey(progress => progress.UserId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() .ToTable(table => { @@ -223,6 +312,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Property(preference => preference.AudioTrackLabel) .HasMaxLength(128); + modelBuilder.Entity() + .HasOne(preference => preference.Profile) + .WithOne() + .HasForeignKey(preference => preference.UserId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() .HasIndex(b => b.MikanId) .IsUnique(); @@ -294,5 +389,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany(c => c.Messages) .HasForeignKey(m => m.ConversationId) .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(conversation => new { conversation.ProfileId, conversation.UpdatedAt }); + + modelBuilder.Entity() + .HasOne(conversation => conversation.Profile) + .WithMany() + .HasForeignKey(conversation => conversation.ProfileId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/SecondDimensionWatcherReDive/Models/ChatConversation.cs b/SecondDimensionWatcherReDive/Models/ChatConversation.cs index 07cd5a9..f0e262f 100644 --- a/SecondDimensionWatcherReDive/Models/ChatConversation.cs +++ b/SecondDimensionWatcherReDive/Models/ChatConversation.cs @@ -3,6 +3,8 @@ namespace SecondDimensionWatcherReDive.Models; public class ChatConversation { public Guid Id { get; set; } + public Guid ProfileId { get; set; } + public UserProfile Profile { get; set; } = null!; public string? Title { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } diff --git a/SecondDimensionWatcherReDive/Models/LoginSession.cs b/SecondDimensionWatcherReDive/Models/LoginSession.cs new file mode 100644 index 0000000..c8aa129 --- /dev/null +++ b/SecondDimensionWatcherReDive/Models/LoginSession.cs @@ -0,0 +1,17 @@ +namespace SecondDimensionWatcherReDive.Models; + +public sealed class LoginSession +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public UserAccount User { get; set; } = null!; + public Guid ActiveProfileId { get; set; } + public UserProfile ActiveProfile { get; set; } = null!; + public string RefreshTokenHash { get; set; } = string.Empty; + public string? DeviceName { get; set; } + public DateTimeOffset AuthenticatedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastSeenAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } +} diff --git a/SecondDimensionWatcherReDive/Models/PlaybackPreference.cs b/SecondDimensionWatcherReDive/Models/PlaybackPreference.cs index 4381da2..9785b77 100644 --- a/SecondDimensionWatcherReDive/Models/PlaybackPreference.cs +++ b/SecondDimensionWatcherReDive/Models/PlaybackPreference.cs @@ -4,6 +4,8 @@ public class PlaybackPreference { public Guid UserId { get; set; } + public UserProfile? Profile { get; set; } + public string? SubtitleLanguage { get; set; } public string? SubtitleTrackLabel { get; set; } diff --git a/SecondDimensionWatcherReDive/Models/PlaybackProgress.cs b/SecondDimensionWatcherReDive/Models/PlaybackProgress.cs index 219ea48..ac573db 100644 --- a/SecondDimensionWatcherReDive/Models/PlaybackProgress.cs +++ b/SecondDimensionWatcherReDive/Models/PlaybackProgress.cs @@ -6,6 +6,8 @@ public class PlaybackProgress public Guid UserId { get; set; } + public UserProfile? Profile { get; set; } + public Guid AnimationInfoId { get; set; } public AnimationInfo? AnimationInfo { get; set; } diff --git a/SecondDimensionWatcherReDive/Models/UserAccount.cs b/SecondDimensionWatcherReDive/Models/UserAccount.cs new file mode 100644 index 0000000..4eed36a --- /dev/null +++ b/SecondDimensionWatcherReDive/Models/UserAccount.cs @@ -0,0 +1,16 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Models; + +public sealed class UserAccount +{ + public Guid Id { get; set; } + public string Username { get; set; } = string.Empty; + public string? PasswordHash { get; set; } + public UserRole Role { get; set; } + public bool IsDisabled { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public ICollection Profiles { get; set; } = []; + public ICollection Sessions { get; set; } = []; +} diff --git a/SecondDimensionWatcherReDive/Models/UserProfile.cs b/SecondDimensionWatcherReDive/Models/UserProfile.cs new file mode 100644 index 0000000..02674ad --- /dev/null +++ b/SecondDimensionWatcherReDive/Models/UserProfile.cs @@ -0,0 +1,14 @@ +namespace SecondDimensionWatcherReDive.Models; + +public sealed class UserProfile +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public UserAccount User { get; set; } = null!; + public string Name { get; set; } = string.Empty; + public string? Avatar { get; set; } + public string? PinHash { get; set; } + public bool IsDefault { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/SecondDimensionWatcherReDive/Models/WebDavToken.cs b/SecondDimensionWatcherReDive/Models/WebDavToken.cs index bf88ac0..b284153 100644 --- a/SecondDimensionWatcherReDive/Models/WebDavToken.cs +++ b/SecondDimensionWatcherReDive/Models/WebDavToken.cs @@ -4,6 +4,10 @@ public class WebDavToken { public Guid Id { get; set; } + public Guid UserId { get; set; } + + public UserAccount User { get; set; } = null!; + public string Username { get; set; } = string.Empty; public string TokenHash { get; set; } = string.Empty; @@ -11,4 +15,12 @@ public class WebDavToken public string? Description { get; set; } public DateTimeOffset CreatedAt { get; set; } + + public string Scope { get; set; } = "read"; + + public string VirtualRoot { get; set; } = "/"; + + public DateTimeOffset? ExpiresAt { get; set; } + + public DateTimeOffset? RevokedAt { get; set; } } diff --git a/SecondDimensionWatcherReDive/Program.cs b/SecondDimensionWatcherReDive/Program.cs index 80f5f19..63e1e63 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -19,6 +19,7 @@ using SecondDimensionWatcherReDive.Framework.FileDownload; using SecondDimensionWatcherReDive.Framework.FileStore; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Authorization; using SecondDimensionWatcherReDive.Framework.Tasks; using SecondDimensionWatcherReDive.Inference.AI; using SecondDimensionWatcherReDive.Models; @@ -120,7 +121,7 @@ ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, - RequireExpirationTime = false + RequireExpirationTime = true }; builder.Services.AddSingleton(tokenValidationParams); @@ -134,9 +135,76 @@ { options.SaveToken = true; options.TokenValidationParameters = tokenValidationParams; + options.Events = new JwtBearerEvents + { + OnTokenValidated = async context => + { + var principal = context.Principal; + if (principal is null + || !principal.TryGetUserId(out var userId) + || !principal.TryGetProfileId(out var profileId) + || !principal.TryGetSessionId(out var sessionId)) + { + context.Fail("The access token has no valid session identity."); + return; + } + + var repository = context.HttpContext.RequestServices + .GetRequiredService(); + var authenticated = await repository.GetAuthenticatedSessionAsync( + sessionId, + DateTimeOffset.UtcNow, + context.HttpContext.RequestAborted); + var authenticatedAt = principal.FindFirst( + IdentityClaimTypes.AuthenticatedAt)?.Value; + if (authenticated is null + || authenticated.User.Id != userId + || authenticated.Profile.Id != profileId + || !string.Equals( + principal.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value, + authenticated.User.Role.ToString(), + StringComparison.Ordinal) + || authenticatedAt != authenticated.Session.AuthenticatedAt + .ToUnixTimeSeconds() + .ToString(System.Globalization.CultureInfo.InvariantCulture)) + { + context.Fail("The login session is no longer active."); + } + } + }; }).AddScheme( BasicAuthenticationHandler.SchemeName, _ => { }); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy(AccessPolicies.ContentWrite, + policy => policy.RequireRole(nameof(UserRole.Admin), nameof(UserRole.Member))); + options.AddPolicy(AccessPolicies.PlaybackWrite, + policy => policy.RequireRole(nameof(UserRole.Admin), nameof(UserRole.Member))); + options.AddPolicy(AccessPolicies.ChatWrite, + policy => policy.RequireRole(nameof(UserRole.Admin), nameof(UserRole.Member))); + options.AddPolicy(AccessPolicies.Administrator, + policy => policy.RequireRole(nameof(UserRole.Admin))); + static bool HasRecentAuthentication(System.Security.Claims.ClaimsPrincipal principal) + { + if (!long.TryParse( + principal.FindFirst(IdentityClaimTypes.AuthenticatedAt)?.Value, + out var unixSeconds)) + return false; + var authenticatedAt = DateTimeOffset.FromUnixTimeSeconds(unixSeconds); + var age = DateTimeOffset.UtcNow - authenticatedAt; + return age >= TimeSpan.FromMinutes(-1) && age <= TimeSpan.FromMinutes(5); + } + + options.AddPolicy(AccessPolicies.RecentAuthentication, + policy => policy.RequireAssertion(context => HasRecentAuthentication(context.User))); + options.AddPolicy(AccessPolicies.RecentAdministrator, policy => + { + policy.RequireRole(nameof(UserRole.Admin)); + policy.RequireAssertion(context => HasRecentAuthentication(context.User)); + }); +}); + //Add distributed cache (Valkey / Redis or in-memory fallback) var valkeyConnection = builder.Configuration["Valkey:ConnectionString"]; if (!string.IsNullOrEmpty(valkeyConnection)) @@ -275,6 +343,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); @@ -351,6 +421,7 @@ app.MapFallbackToFile("index.html"); } +app.UseAuthentication(); app.UseAuthorization(); if (app.Configuration.GetValue("DisableCors") is true) app.UseCors("all"); diff --git a/SecondDimensionWatcherReDive/Repositories/ChatRepository.cs b/SecondDimensionWatcherReDive/Repositories/ChatRepository.cs index 337a5f7..4144ecd 100644 --- a/SecondDimensionWatcherReDive/Repositories/ChatRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/ChatRepository.cs @@ -7,21 +7,27 @@ namespace SecondDimensionWatcherReDive.Repositories; public class ChatRepository(ApplicationContext context) : IChatRepository { public async Task> GetConversationsAsync( + Guid profileId, CancellationToken cancellationToken) { return await context.ChatConversations .AsNoTracking() + .Where(c => c.ProfileId == profileId) .OrderByDescending(c => c.UpdatedAt) .Select(c => new ChatConversationSummary(c.Id, c.Title, c.CreatedAt, c.UpdatedAt)) .ToListAsync(cancellationToken); } public async Task GetConversationWithMessagesAsync( - Guid id, CancellationToken cancellationToken) + Guid id, + Guid profileId, + CancellationToken cancellationToken) { var conversation = await context.ChatConversations .AsNoTracking() - .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + .FirstOrDefaultAsync( + c => c.Id == id && c.ProfileId == profileId, + cancellationToken); if (conversation is null) return null; @@ -40,12 +46,15 @@ public async Task> GetConversationsAsync( } public async Task CreateConversationAsync( - string? title, CancellationToken cancellationToken) + Guid profileId, + string? title, + CancellationToken cancellationToken) { var now = DateTimeOffset.Now; var entity = new ChatConversation { Id = Guid.NewGuid(), + ProfileId = profileId, Title = title, CreatedAt = now, UpdatedAt = now @@ -57,9 +66,14 @@ public async Task CreateConversationAsync( return new ChatConversationSummary(entity.Id, entity.Title, entity.CreatedAt, entity.UpdatedAt); } - public async Task DeleteConversationAsync(Guid id, CancellationToken cancellationToken) + public async Task DeleteConversationAsync( + Guid id, + Guid profileId, + CancellationToken cancellationToken) { - var entity = await context.ChatConversations.FindAsync([id], cancellationToken); + var entity = await context.ChatConversations.FirstOrDefaultAsync( + conversation => conversation.Id == id && conversation.ProfileId == profileId, + cancellationToken); if (entity is null) return false; context.ChatConversations.Remove(entity); @@ -68,9 +82,14 @@ public async Task DeleteConversationAsync(Guid id, CancellationToken cance } public async Task UpdateConversationTitleAsync( - Guid id, string title, CancellationToken cancellationToken) + Guid id, + Guid profileId, + string title, + CancellationToken cancellationToken) { - var entity = await context.ChatConversations.FindAsync([id], cancellationToken); + var entity = await context.ChatConversations.FirstOrDefaultAsync( + conversation => conversation.Id == id && conversation.ProfileId == profileId, + cancellationToken); if (entity is null) return; entity.Title = title; @@ -79,8 +98,17 @@ public async Task UpdateConversationTitleAsync( } public async Task AddMessageAsync( - Guid conversationId, ChatMessageRecord message, CancellationToken cancellationToken) + Guid conversationId, + Guid profileId, + ChatMessageRecord message, + CancellationToken cancellationToken) { + var conversation = await context.ChatConversations.FirstOrDefaultAsync( + candidate => candidate.Id == conversationId + && candidate.ProfileId == profileId, + cancellationToken); + if (conversation is null) return; + var entity = new ChatMessage { Id = message.Id, @@ -97,16 +125,23 @@ public async Task AddMessageAsync( context.ChatMessages.Add(entity); // Update conversation timestamp - var conversation = await context.ChatConversations.FindAsync([conversationId], cancellationToken); - if (conversation is not null) - conversation.UpdatedAt = DateTimeOffset.Now; + conversation.UpdatedAt = DateTimeOffset.Now; await context.SaveChangesAsync(cancellationToken); } public async Task AddMessagesAsync( - Guid conversationId, IEnumerable messages, CancellationToken cancellationToken) + Guid conversationId, + Guid profileId, + IEnumerable messages, + CancellationToken cancellationToken) { + var conversation = await context.ChatConversations.FirstOrDefaultAsync( + candidate => candidate.Id == conversationId + && candidate.ProfileId == profileId, + cancellationToken); + if (conversation is null) return; + foreach (var message in messages) { context.ChatMessages.Add(new ChatMessage @@ -123,19 +158,20 @@ public async Task AddMessagesAsync( }); } - var conversation = await context.ChatConversations.FindAsync([conversationId], cancellationToken); - if (conversation is not null) - conversation.UpdatedAt = DateTimeOffset.Now; + conversation.UpdatedAt = DateTimeOffset.Now; await context.SaveChangesAsync(cancellationToken); } public async Task> GetMessagesAsync( - Guid conversationId, CancellationToken cancellationToken) + Guid conversationId, + Guid profileId, + CancellationToken cancellationToken) { return await context.ChatMessages .AsNoTracking() - .Where(m => m.ConversationId == conversationId) + .Where(m => m.ConversationId == conversationId + && m.Conversation.ProfileId == profileId) .OrderBy(m => m.Order) .Select(m => new ChatMessageRecord( m.Id, m.Role, m.Content, m.ToolCallsJson, @@ -144,9 +180,14 @@ public async Task> GetMessagesAsync( } public async Task GetMessageCountAsync( - Guid conversationId, CancellationToken cancellationToken) + Guid conversationId, + Guid profileId, + CancellationToken cancellationToken) { return await context.ChatMessages - .CountAsync(m => m.ConversationId == conversationId, cancellationToken); + .CountAsync( + m => m.ConversationId == conversationId + && m.Conversation.ProfileId == profileId, + cancellationToken); } } diff --git a/SecondDimensionWatcherReDive/Repositories/IdentityRepository.cs b/SecondDimensionWatcherReDive/Repositories/IdentityRepository.cs new file mode 100644 index 0000000..9b61063 --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/IdentityRepository.cs @@ -0,0 +1,381 @@ +using System.Data; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using ProfileEntity = SecondDimensionWatcherReDive.Models.UserProfile; +using SessionEntity = SecondDimensionWatcherReDive.Models.LoginSession; +using UserEntity = SecondDimensionWatcherReDive.Models.UserAccount; + +namespace SecondDimensionWatcherReDive.Repositories; + +public sealed class IdentityRepository(Models.ApplicationContext context) : IIdentityRepository +{ + public Task AnyUsersAsync(CancellationToken cancellationToken) => + context.Users.AnyAsync(cancellationToken); + + public async Task FindUserByIdAsync( + Guid id, + CancellationToken cancellationToken) => + (await context.Users.AsNoTracking() + .FirstOrDefaultAsync(user => user.Id == id, cancellationToken))?.ToRecord(); + + public async Task FindUserByUsernameAsync( + string username, + CancellationToken cancellationToken) + { + var normalized = username.Trim().ToLowerInvariant(); + return (await context.Users.AsNoTracking() + .FirstOrDefaultAsync(user => user.Username == normalized, cancellationToken))?.ToRecord(); + } + + public async Task FindProfileAsync( + Guid id, + CancellationToken cancellationToken) => + (await context.Profiles.AsNoTracking() + .FirstOrDefaultAsync(profile => profile.Id == id, cancellationToken))?.ToRecord(); + + public async Task> GetProfilesAsync( + Guid userId, + CancellationToken cancellationToken) => + (await context.Profiles.AsNoTracking() + .Where(profile => profile.UserId == userId) + .OrderByDescending(profile => profile.IsDefault) + .ThenBy(profile => profile.Name) + .ToListAsync(cancellationToken)) + .Select(profile => profile.ToRecord()) + .ToList(); + + public async Task CreateUserWithProfileAsync( + UserAccount user, + UserProfile profile, + CancellationToken cancellationToken) + { + context.Users.Add(user.ToEntity()); + context.Profiles.Add(profile.ToEntity()); + // A single SaveChanges call is transactionally atomic and is executed by + // Npgsql's configured retry strategy. An explicit user transaction here + // would be rejected by EnableRetryOnFailure in production. + try + { + await context.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) when (IsUniqueViolation(exception)) + { + context.ChangeTracker.Clear(); + throw new IdentityConflictException( + "A user or profile with the same identity already exists.", exception); + } + return new UserAccountWithProfiles(user, [profile]); + } + + public async Task SetPasswordHashAsync( + Guid userId, + string passwordHash, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var affected = await context.Users + .Where(user => user.Id == userId && !user.IsDisabled) + .ExecuteUpdateAsync(setters => setters + .SetProperty(user => user.PasswordHash, passwordHash) + .SetProperty(user => user.UpdatedAt, now), cancellationToken); + return affected == 1; + } + + public async Task AddProfileAsync( + UserProfile profile, + CancellationToken cancellationToken) + { + context.Profiles.Add(profile.ToEntity()); + try + { + await context.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) when (IsUniqueViolation(exception)) + { + context.ChangeTracker.Clear(); + throw new IdentityConflictException( + "A profile with the same name already exists for this user.", exception); + } + return profile; + } + + public async Task UpdateProfileAsync( + Guid profileId, + Guid userId, + string name, + string? avatar, + string? pinHash, + bool replacePin, + DateTimeOffset now, + CancellationToken cancellationToken) + { + int affected; + try + { + affected = await context.Profiles + .Where(profile => profile.Id == profileId && profile.UserId == userId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(profile => profile.Name, name) + .SetProperty(profile => profile.Avatar, avatar) + .SetProperty(profile => profile.PinHash, + profile => replacePin ? pinHash : profile.PinHash) + .SetProperty(profile => profile.UpdatedAt, now), cancellationToken); + } + catch (Exception exception) when (IsUniqueViolation(exception)) + { + throw new IdentityConflictException( + "A profile with the same name already exists for this user.", exception); + } + return affected == 1; + } + + public async Task> GetUsersAsync( + CancellationToken cancellationToken) + { + var users = await context.Users.AsNoTracking() + .OrderBy(user => user.Username) + .ToListAsync(cancellationToken); + var profiles = await context.Profiles.AsNoTracking() + .OrderByDescending(profile => profile.IsDefault) + .ThenBy(profile => profile.Name) + .ToListAsync(cancellationToken); + var byUser = profiles.ToLookup(profile => profile.UserId); + return users.Select(user => new UserAccountWithProfiles( + user.ToRecord(), + byUser[user.Id].Select(profile => profile.ToRecord()).ToList())) + .ToList(); + } + + public async Task UpdateUserAccessAsync( + Guid userId, + UserRole role, + bool isDisabled, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var strategy = context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await context.Database.BeginTransactionAsync( + IsolationLevel.ReadCommitted, cancellationToken); + // Serialize the household-admin invariant across independent users. A row lock on + // only the target cannot prevent two administrators from demoting each other. + await context.Database.ExecuteSqlRawAsync( + "SELECT pg_advisory_xact_lock(600000000000000017)", + cancellationToken); + + var target = await context.Users.FirstOrDefaultAsync( + user => user.Id == userId, cancellationToken); + if (target is null) + { + await transaction.RollbackAsync(cancellationToken); + return UpdateUserAccessResult.NotFound; + } + + if (target.Role == UserRole.Admin + && !target.IsDisabled + && (role != UserRole.Admin || isDisabled) + && !await context.Users.AnyAsync( + user => user.Id != userId + && user.Role == UserRole.Admin + && !user.IsDisabled, + cancellationToken)) + { + await transaction.RollbackAsync(cancellationToken); + return UpdateUserAccessResult.LastAdministrator; + } + + var accessChanged = target.Role != role || target.IsDisabled != isDisabled; + target.Role = role; + target.IsDisabled = isDisabled; + target.UpdatedAt = now; + await context.SaveChangesAsync(cancellationToken); + if (accessChanged) + { + await context.LoginSessions + .Where(session => session.UserId == userId && session.RevokedAt == null) + .ExecuteUpdateAsync(setters => setters + .SetProperty(session => session.RevokedAt, now), cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + return UpdateUserAccessResult.Updated; + }); + } + + public async Task AddSessionAsync( + UserSession session, + CancellationToken cancellationToken) + { + context.LoginSessions.Add(session.ToEntity()); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task GetAuthenticatedSessionAsync( + Guid sessionId, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var entity = await context.LoginSessions.AsNoTracking() + .Include(session => session.User) + .Include(session => session.ActiveProfile) + .FirstOrDefaultAsync(session => session.Id == sessionId + && session.RevokedAt == null + && session.ExpiresAt > now + && !session.User.IsDisabled, + cancellationToken); + if (entity is null || entity.ActiveProfile.UserId != entity.UserId) + return null; + return new AuthenticatedSession( + entity.User.ToRecord(), + entity.ActiveProfile.ToRecord(), + entity.ToRecord()); + } + + public async Task TryRotateSessionAsync( + Guid sessionId, + string expectedRefreshTokenHash, + string newRefreshTokenHash, + Guid activeProfileId, + DateTimeOffset? authenticatedAt, + DateTimeOffset now, + DateTimeOffset expiresAt, + CancellationToken cancellationToken) + { + var affected = await context.LoginSessions + .Where(session => session.Id == sessionId + && session.RefreshTokenHash == expectedRefreshTokenHash + && session.RevokedAt == null + && session.ExpiresAt > now + && context.Profiles.Any(profile => + profile.Id == activeProfileId + && profile.UserId == session.UserId)) + .ExecuteUpdateAsync(setters => setters + .SetProperty(session => session.RefreshTokenHash, newRefreshTokenHash) + .SetProperty(session => session.ActiveProfileId, activeProfileId) + .SetProperty(session => session.AuthenticatedAt, + session => authenticatedAt ?? session.AuthenticatedAt) + .SetProperty(session => session.LastSeenAt, now) + .SetProperty(session => session.ExpiresAt, expiresAt), cancellationToken); + return affected == 1; + } + + public async Task> GetSessionsAsync( + Guid? userId, + CancellationToken cancellationToken) + { + var query = context.LoginSessions.AsNoTracking().AsQueryable(); + if (userId.HasValue) + query = query.Where(session => session.UserId == userId.Value); + return await query + .OrderByDescending(session => session.LastSeenAt) + .Select(session => new UserSessionSummary( + new UserSession( + session.Id, + session.UserId, + session.ActiveProfileId, + string.Empty, + session.DeviceName, + session.AuthenticatedAt, + session.CreatedAt, + session.LastSeenAt, + session.ExpiresAt, + session.RevokedAt), + session.User.Username, + session.ActiveProfile.Name)) + .ToListAsync(cancellationToken); + } + + public async Task RevokeSessionAsync( + Guid sessionId, + Guid? requiredUserId, + DateTimeOffset revokedAt, + CancellationToken cancellationToken) + { + var affected = await context.LoginSessions + .Where(session => session.Id == sessionId + && session.RevokedAt == null + && (!requiredUserId.HasValue + || session.UserId == requiredUserId.Value)) + .ExecuteUpdateAsync(setters => setters + .SetProperty(session => session.RevokedAt, revokedAt), cancellationToken); + return affected == 1; + } + + private static bool IsUniqueViolation(Exception exception) => + exception is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation } + || exception.InnerException is not null && IsUniqueViolation(exception.InnerException); + +} + +internal static class IdentityRepositoryConverter +{ + internal static UserAccount ToRecord(this UserEntity entity) => new( + entity.Id, + entity.Username, + entity.PasswordHash, + entity.Role, + entity.IsDisabled, + entity.CreatedAt, + entity.UpdatedAt); + + internal static UserProfile ToRecord(this ProfileEntity entity) => new( + entity.Id, + entity.UserId, + entity.Name, + entity.Avatar, + entity.PinHash, + entity.IsDefault, + entity.CreatedAt, + entity.UpdatedAt); + + internal static UserSession ToRecord(this SessionEntity entity) => new( + entity.Id, + entity.UserId, + entity.ActiveProfileId, + entity.RefreshTokenHash, + entity.DeviceName, + entity.AuthenticatedAt, + entity.CreatedAt, + entity.LastSeenAt, + entity.ExpiresAt, + entity.RevokedAt); + + internal static UserEntity ToEntity(this UserAccount record) => new() + { + Id = record.Id, + Username = record.Username, + PasswordHash = record.PasswordHash, + Role = record.Role, + IsDisabled = record.IsDisabled, + CreatedAt = record.CreatedAt, + UpdatedAt = record.UpdatedAt + }; + + internal static ProfileEntity ToEntity(this UserProfile record) => new() + { + Id = record.Id, + UserId = record.UserId, + Name = record.Name, + Avatar = record.Avatar, + PinHash = record.PinHash, + IsDefault = record.IsDefault, + CreatedAt = record.CreatedAt, + UpdatedAt = record.UpdatedAt + }; + + internal static SessionEntity ToEntity(this UserSession record) => new() + { + Id = record.Id, + UserId = record.UserId, + ActiveProfileId = record.ActiveProfileId, + RefreshTokenHash = record.RefreshTokenHash, + DeviceName = record.DeviceName, + AuthenticatedAt = record.AuthenticatedAt, + CreatedAt = record.CreatedAt, + LastSeenAt = record.LastSeenAt, + ExpiresAt = record.ExpiresAt, + RevokedAt = record.RevokedAt + }; +} diff --git a/SecondDimensionWatcherReDive/Repositories/RepositoryConverter.cs b/SecondDimensionWatcherReDive/Repositories/RepositoryConverter.cs index 260e342..45417c2 100644 --- a/SecondDimensionWatcherReDive/Repositories/RepositoryConverter.cs +++ b/SecondDimensionWatcherReDive/Repositories/RepositoryConverter.cs @@ -107,10 +107,15 @@ public static DataRepo.SubscriptionAutomationPolicy ToRecord( public static DataRepo.WebDavToken ToRecord(this Models.WebDavToken entity) => new(entity.Id, + entity.UserId, entity.Username, entity.TokenHash, entity.Description, - entity.CreatedAt); + entity.CreatedAt, + entity.Scope, + entity.VirtualRoot, + entity.ExpiresAt, + entity.RevokedAt); public static DataRepo.PlaybackProgress ToRecord(this Models.PlaybackProgress entity) => new(entity.Id, @@ -259,10 +264,15 @@ public static Models.WebDavToken ToEntity(this DataRepo.WebDavToken record) => new() { Id = record.Id, + UserId = record.UserId, Username = record.Username, TokenHash = record.TokenHash, Description = record.Description, - CreatedAt = record.CreatedAt + CreatedAt = record.CreatedAt, + Scope = record.Scope, + VirtualRoot = record.VirtualRoot, + ExpiresAt = record.ExpiresAt, + RevokedAt = record.RevokedAt }; public static Models.PlaybackProgress ToEntity(this DataRepo.PlaybackProgress record) => diff --git a/SecondDimensionWatcherReDive/Repositories/WebDavTokenRepository.cs b/SecondDimensionWatcherReDive/Repositories/WebDavTokenRepository.cs index 39e06a4..8e23db6 100644 --- a/SecondDimensionWatcherReDive/Repositories/WebDavTokenRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/WebDavTokenRepository.cs @@ -33,12 +33,16 @@ public async Task AddAsync(WebDavToken token, CancellationToken cancellationToke await context.SaveChangesAsync(cancellationToken); } - public async Task RemoveByIdAsync(Guid id, CancellationToken cancellationToken) + public async Task RevokeByIdAsync( + Guid id, + DateTimeOffset revokedAt, + CancellationToken cancellationToken) { var entity = await context.WebDavTokens.FindAsync([id], cancellationToken); if (entity is null) return false; - context.WebDavTokens.Remove(entity); + if (entity.RevokedAt is null) + entity.RevokedAt = revokedAt; await context.SaveChangesAsync(cancellationToken); return true; }