Auth via authorized device

This commit is contained in:
2025-11-30 00:00:13 +08:00
parent 00b3087d6a
commit a8b62fb0eb
3 changed files with 280 additions and 0 deletions

View File

@@ -275,6 +275,14 @@ public class AuthController(
public string Token { get; set; } = string.Empty; public string Token { get; set; } = string.Empty;
} }
public class NewSessionRequest
{
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
[MaxLength(1024)] public string? DeviceName { get; set; }
[Required] public DysonNetwork.Shared.Models.ClientPlatform Platform { get; set; }
public Instant? ExpiredAt { get; set; }
}
[HttpPost("token")] [HttpPost("token")]
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request) public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
{ {
@@ -325,4 +333,35 @@ public class AuthController(
}); });
return Ok(); return Ok();
} }
[HttpPost("login/session")]
[Microsoft.AspNetCore.Authorization.Authorize] // Use full namespace to avoid ambiguity with DysonNetwork.Pass.Permission.Authorize
public async Task<ActionResult<TokenExchangeResponse>> LoginFromSession([FromBody] NewSessionRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not Shared.Models.SnAuthSession currentSession) return Unauthorized();
var newSession = await auth.CreateSessionFromParentAsync(
currentSession,
request.DeviceId,
request.DeviceName,
request.Platform,
request.ExpiredAt
);
var tk = auth.CreateToken(newSession);
// Set cookie using HttpContext, similar to CreateSessionAndIssueToken
var cookieDomain = _cookieDomain;
HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Domain = cookieDomain,
Expires = request.ExpiredAt?.ToDateTimeOffset() ?? DateTime.UtcNow.AddYears(20)
});
return Ok(new TokenExchangeResponse { Token = tk });
}
} }

View File

@@ -664,4 +664,40 @@ public class AuthService(
return Convert.FromBase64String(padded); return Convert.FromBase64String(padded);
} }
/// <summary>
/// Creates a new session derived from an existing parent session.
/// </summary>
/// <param name="parentSession">The existing session from which the new session is derived.</param>
/// <param name="deviceId">The ID of the device for the new session.</param>
/// <param name="deviceName">The name of the device for the new session.</param>
/// <param name="platform">The platform of the device for the new session.</param>
/// <param name="expiredAt">Optional: The expiration time for the new session.</param>
/// <returns>The newly created SnAuthSession.</returns>
public async Task<SnAuthSession> CreateSessionFromParentAsync(
SnAuthSession parentSession,
string deviceId,
string? deviceName,
ClientPlatform platform,
Instant? expiredAt = null
)
{
var device = await GetOrCreateDeviceAsync(parentSession.AccountId, deviceId, deviceName, platform);
var now = SystemClock.Instance.GetCurrentInstant();
var session = new SnAuthSession
{
AccountId = parentSession.AccountId,
CreatedAt = now,
LastGrantedAt = now,
ExpiredAt = expiredAt,
ParentSessionId = parentSession.Id,
ClientId = device.Id,
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
return session;
}
} }

View File

@@ -0,0 +1,205 @@
# Web to Local Credential Sharing for Flutter Desktop Apps
This document outlines the essential features and concepts for implementing secure web to local credential sharing for Flutter desktop applications. The goal is to allow a web application to establish an authenticated session with a running desktop application, leveraging the desktop app's existing authentication and maintaining a secure, hierarchical session structure.
## Core Concepts from DysonNetwork.Pass Refactoring
When accessing the Pass service through the Gateway, replace the `/api` with `/pass`
The recent refactoring of the authentication system in `DysonNetwork.Pass` introduces key mechanisms that directly support this web-to-local credential sharing:
1. **Parent/Sub-Sessions (`SnAuthSession.ParentSessionId`)**:
* The `SnAuthSession` model now includes a `ParentSessionId` field. This allows an authenticated session to explicitly declare that it was derived from another session.
* This is crucial for the web-to-local flow, as the web session can be established as a child of the desktop app's primary session.
2. **Recursive Session Revocation**:
* The `AuthService.RevokeSessionAsync` method has been updated to recursively revoke all child sessions (and their children) when a parent session is logged out.
* This ensures that if a user logs out of their desktop application, all web sessions that were derived from that desktop session are also automatically invalidated, enhancing security and maintaining consistency.
3. **Login from Existing Session API (`AuthController.LoginFromSession`)**:
* A new API endpoint `POST /api/auth/login/session` has been added to `AuthController`.
* This endpoint is designed to create a new `SnAuthSession` (and issue a corresponding authentication token/cookie) by leveraging an *existing* authenticated session.
* It takes device information (`DeviceId`, `DeviceName`, `Platform`, `ExpiredAt`) and the `ParentSessionId` is implicitly set to the `currentSession` available in the `HttpContext`.
* This endpoint is the server-side counterpart to the desktop app's `/exchange` endpoint, allowing the desktop app to request a new, child session for the web application.
## Integration into the Web-to-Local Flow
The `AuthController.LoginFromSession` API endpoint plays a central role in the web-to-local credential sharing mechanism. After the Flutter desktop app's local HTTP server receives and verifies a server-signed challenge from the web app (via its `/exchange` endpoint), the desktop app would then call this `LoginFromSession` API endpoint.
By making this call:
* The desktop app, being already authenticated with the server, provides its active session context.
* The `LoginFromSession` endpoint uses this context to create a *new* session for the web application.
* This new web session is automatically linked to the desktop app's session via `ParentSessionId`.
* A new web session token (e.g., a JWT) is issued for the web app.
This setup ensures that:
* The web session is securely tied to the desktop session.
* The web session benefits from the recursive revocation logic, meaning if the desktop app session is terminated, the web session is also automatically invalidated.
---
## ✅ Feature Checklist for the Flutter Desktop App
1. **Localhost HTTP Server**
Your Flutter desktop app must include:
* A lightweight HTTP server (`dart:io HttpServer`)
* Bind to `127.0.0.1` only (never `0.0.0.0`)
* Use a random port on startup (e.g., `4000060000`)
* Store this port in memory
Endpoints required:
1. `GET /alive`
* Used by the web app to detect that the desktop app is running
* Returns JSON: `{ "status": "ok", "challenge": "<randomChallenge>" }`
2. `POST /exchange`
* Web app sends the server-signed challenge back
* Desktop verifies and replies with a signed token/session
* **Crucially, this is where the desktop app would call the `POST /api/auth/login/session` endpoint on the backend, using its existing session to create a new sub-session for the web app.**
3. `POST /handshake/done` (optional)
* For cleanup, closing UI, etc.
4. `GET /handshake` (optional)
* For some client information and ensure it's Solian's app
---
2. **Challenge/Response Security System**
To avoid any malicious website calling your localhost server, implement:
Desktop app responsibilities:
* Generate a random challenge string (length `3264`)
* Include it in `/alive` response
* When receiving `/exchange`, verify:
* The challenge was signed by your backend
* The signature or token is valid
* Only then return a desktop session token
Requirements:
* Challenge must be valid only once
* Challenge must expire in `≤ 30` seconds
* Challenge tied to desktop session ID
---
3. **Communication With Your Backend**
The desktop app must:
* Use its existing authentication session (local token, refresh token, etc.)
* When receiving the signed challenge from web app:
1. Send the challenge + desktop login token to backend
2. Backend verifies that:
* Desktop user is authenticated
* Challenge matches web request
* Receive a web-session-token from backend (This is the token issued by `AuthController.LoginFromSession`)
* Return it to the web app via `/exchange`
---
4. **Local HTTP Server CORS Headers**
Your desktop server must include:
`Access-Control-Allow-Origin: https://your-web-domain.com`
`Access-Control-Allow-Headers: *`
`Access-Control-Allow-Methods: GET, POST, OPTIONS`
Also allow:
* Preflight `OPTIONS` requests
This lets browser JavaScript call your localhost server directly.
---
5. **Random Port Broadcasting**
On startup, the desktop app picks a random port and exposes:
* `/alive`
* `/exchange`
But the web app needs to know the port.
Two solutions:
Option A (simple):
Web app scans 20 known ports (e.g., `4100041020`).
Option B (secure):
Desktop app writes port to:
* macOS: `~/Library/Application Support/MyApp/port.json`
* Windows: `%APPDATA%/MyApp/port.json`
* Linux: `~/.config/MyApp/port.json`
Web app then tries only one port if user clicks “Connect Desktop”.
---
6. **Custom Protocol (Optional but helpful)**
Register custom protocol:
`solian://auth/connect`
Used to trigger desktop app if its closed.
Flow:
1. Web app tries localhost discovery
2. If not found → open `solian://auth/connect?some args`
3. Desktop app starts → exposes localhost server
4. Web page retries detection
---
7. **App UI Behavior**
The desktop UI should:
* Run the HTTP server silently in background
* Possibly show a “Connecting to web” indicator
* Close or hide handshake window after success
* Notify user if login sync succeeded
---
8. **Logging**
Implement basic logs:
* Server started on port `XXX`
* Received `/alive`
* Verified challenge
* Sent credentials to web
* Errors or invalid tokens
Logs should never include full tokens.
---
9. **Optional: WebSocket Support**
Not required, but improves performance.
* Web app connects `ws://localhost:<port>/ws`
* Faster challenge exchange
* Real-time two-way handshake
---
## ⭐ Summary: Desktop App Needs to Implement
Core
* Local HTTP server on `127.0.0.1:<random_port>`
* `/alive` endpoint with random challenge
* `/exchange` endpoint to finish login
* CORS + preflight support
* Challenge-response security
* Only one-time challenges
Backend communication
* Desktop app verifies web request with backend
* Backend creates session for web (via `AuthController.LoginFromSession`)
* Desktop returns session token to web
Optional
* Custom URI protocol handler (`solian://`)
* Port broadcast file
* WebSocket tunnel