Phase 5A: implement tenant auth foundation across broker, app, and agent

This commit is contained in:
2026-05-21 11:23:19 +08:00
parent c85c4a3f5b
commit c43b7dd7b9
5 changed files with 219 additions and 30 deletions

View File

@@ -111,20 +111,22 @@ Status: ✅ Completed (2026-05-18)
- Off-LAN or failed direct attempts still open fullscreen photo through broker fallback.
- User-facing fullscreen flow remains one tap with no extra prompts.
## Phase 5: Platform Integration & Hardening
## Phase 5A: Multi-user Foundation (Google + Tenant Isolation)
- Align Linux service + Windows task launchers with new network/config settings.
- Add rate limits and upload size checks on LAN endpoint.
- Add migration/upload concurrency controls.
- Add operational logs/metrics for:
- Auth failures
- Direct vs broker upload ratio
- Migration outcomes and duration.
- Introduce explicit ownership in data model:
- `users`, `agents`, `photos` keyed by `user_id`
- One agent owned by exactly one user
- Scope all photo/agent API reads and writes by authenticated `user_id`.
- Add Google auth entry flow for app users:
- Android Google sign-in
- Broker verifies Google ID token
- Broker issues short-lived app JWT plus refresh/session token
- Migrate existing single-user records into one seeded owner account.
### Acceptance
- Windows and Linux agents behave consistently with LAN mode enabled.
- Security and reliability checks pass in repeated real-network tests.
- Cross-user access is blocked on all covered endpoints.
- Existing single-user records are migrated to one seeded owner account.
## Public Interfaces / Config Additions

32
PLAN.md
View File

@@ -12,7 +12,7 @@ that works end-to-end, not a half-finished layer.
| 0 | POC — tunnel, upload relay, reverse fetch | ✅ Done |
| 1 | Android app — pick and upload photos | ✅ Done |
| 2 | Agent UI v1 — status + storage settings (LAN API/UI) | ✅ Done (2026-05-18) |
| TODO | Remaining work backlog (phase grouping TBD) | ⬜ Pending |
| 5A | Multi-user foundation (Google auth + tenant boundaries) | ⬜ Pending |
---
@@ -55,6 +55,28 @@ that works end-to-end, not a half-finished layer.
---
## Phase 5A — Multi-user Foundation (Google Auth + Ownership)
**Goal:** Move from single-user assumptions to shared broker operation with strict
tenant isolation by authenticated user identity.
**Ownership model:**
- `users`, `agents`, and `photos` have explicit `user_id` ownership boundaries
- One agent is owned by exactly one user
- All photo/agent API reads and writes are scoped by authenticated `user_id`
**Auth entry:**
- Android app uses Google sign-in
- Broker verifies Google ID token
- Broker issues app JWT (short-lived) and refresh/session token
### Acceptance
- Cross-user access is blocked on all covered endpoints.
- Existing single-user records are migrated to one seeded owner account.
---
## TODO (Post-Phase 2 Backlog)
Phase grouping beyond Phase 1 is intentionally deferred. Use this backlog to
@@ -83,12 +105,8 @@ pick what will become the next implementation phase(s).
- [ ] Basic pagination or infinite scroll
### Multi-user foundation
- [ ] Data model boundaries: `users`, `agents`, `photos` with explicit ownership keys
- [ ] Add `user_id` scoping to all photo/agent queries and APIs
- [ ] Auth contracts: user JWT + per-agent token format and lifecycle
- [ ] Pairing flow design: agent token issue, rotate, revoke
- [ ] Endpoint auth matrix (public/private, required scopes)
- [ ] Migration plan from current single-user data to multi-user-safe schema
- [ ] Phase 5A scoped in `## Phase 5A — Multi-user Foundation (Google Auth + Ownership)`
- [ ] Remaining auth/pairing lifecycle design follows Phase 5B (to be scoped next)
### Android backup and release
- [ ] WorkManager job — runs on WiFi, respects battery saver

View File

@@ -11,6 +11,7 @@ import (
"net"
"net/http"
"net/netip"
"net/url"
"os"
"path/filepath"
"sort"
@@ -917,13 +918,17 @@ func main() {
go state.startHTTPServer(cfg.AllowedSubnets)
conn, _, err := websocket.DefaultDialer.Dial(brokerURL, nil)
wsURL := brokerURL
if state.token != "" {
wsURL = brokerURL + "?token=" + url.QueryEscape(state.token)
}
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
log.Fatal("connect:", err)
}
defer conn.Close()
state.setConnected(true)
log.Println("connected to broker at", brokerURL)
log.Println("connected to broker at", wsURL)
hello, _ := json.Marshal(Message{
Type: "agent_hello",
LANEndpoints: getLANEndpoints(cfg.BindHost, cfg.BindPort),

View File

@@ -81,11 +81,16 @@ class _UploadPageState extends State<UploadPage> {
Timer? _statusTimer;
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
late final TextEditingController _tokenController;
late final TextEditingController _googleIdTokenController;
String _accessToken = '';
String _refreshToken = '';
@override
void initState() {
super.initState();
_tokenController = TextEditingController();
_googleIdTokenController = TextEditingController();
_loadBrokerSession();
_loadDirectUploadConfig();
_loadGallery();
_statusTimer = Timer.periodic(
@@ -98,9 +103,95 @@ class _UploadPageState extends State<UploadPage> {
void dispose() {
_statusTimer?.cancel();
_tokenController.dispose();
_googleIdTokenController.dispose();
super.dispose();
}
Future<void> _loadBrokerSession() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
setState(() {
_accessToken = prefs.getString('broker_access_token') ?? '';
_refreshToken = prefs.getString('broker_refresh_token') ?? '';
});
}
Future<void> _saveBrokerSession({
required String accessToken,
required String refreshToken,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('broker_access_token', accessToken);
await prefs.setString('broker_refresh_token', refreshToken);
if (!mounted) return;
setState(() {
_accessToken = accessToken;
_refreshToken = refreshToken;
});
}
Future<void> _clearBrokerSession() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('broker_access_token');
await prefs.remove('broker_refresh_token');
if (!mounted) return;
setState(() {
_accessToken = '';
_refreshToken = '';
});
}
Future<bool> _signInWithGoogleIdToken() async {
final idToken = _googleIdTokenController.text.trim();
if (idToken.isEmpty) return false;
try {
final res = await http.post(
Uri.parse('$brokerUrl/auth/google'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'id_token': idToken}),
);
if (res.statusCode != 200) return false;
final body = jsonDecode(res.body);
final accessToken = (body['access_token'] ?? '').toString();
final refreshToken = (body['refresh_token'] ?? '').toString();
if (accessToken.isEmpty || refreshToken.isEmpty) return false;
await _saveBrokerSession(
accessToken: accessToken,
refreshToken: refreshToken,
);
return true;
} catch (_) {
return false;
}
}
Future<String?> _getValidAccessToken() async {
if (_accessToken.isNotEmpty) return _accessToken;
if (_refreshToken.isEmpty) return null;
try {
final res = await http.post(
Uri.parse('$brokerUrl/auth/refresh'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'refresh_token': _refreshToken}),
);
if (res.statusCode != 200) {
await _clearBrokerSession();
return null;
}
final body = jsonDecode(res.body);
final accessToken = (body['access_token'] ?? '').toString();
if (accessToken.isEmpty) return null;
await _saveBrokerSession(
accessToken: accessToken,
refreshToken: _refreshToken,
);
return accessToken;
} catch (_) {
return null;
}
}
Future<void> _loadDirectUploadConfig() async {
final prefs = await SharedPreferences.getInstance();
final cfg = DirectUploadConfig(
@@ -207,7 +298,12 @@ class _UploadPageState extends State<UploadPage> {
Future<List<Uri>> _fetchAgentHints() async {
try {
final res = await http.get(Uri.parse('$brokerUrl/agent/hints'));
final token = await _getValidAccessToken();
if (token == null) return const [];
final res = await http.get(
Uri.parse('$brokerUrl/agent/hints'),
headers: {'Authorization': 'Bearer $token'},
);
if (res.statusCode != 200) return const [];
final body = jsonDecode(res.body);
if (body is! Map<String, dynamic>) return const [];
@@ -244,11 +340,16 @@ class _UploadPageState extends State<UploadPage> {
}
Future<http.StreamedResponse> _uploadViaBroker(File file) async {
final token = await _getValidAccessToken();
if (token == null) {
throw const HttpException('missing broker auth session');
}
final filename = p.basename(file.path);
final req = http.MultipartRequest(
'POST',
Uri.parse('$brokerUrl/upload/$filename'),
);
req.headers['Authorization'] = 'Bearer $token';
req.files.add(await http.MultipartFile.fromPath('file', file.path));
return req.send().timeout(const Duration(seconds: 20));
}
@@ -271,12 +372,15 @@ class _UploadPageState extends State<UploadPage> {
Future<void> _indexDirectUploadToBroker(File file) async {
try {
final token = await _getValidAccessToken();
if (token == null) return;
final thumbBytes = await _buildPhoneThumbnail(file);
if (thumbBytes == null) return;
final req = http.MultipartRequest(
'POST',
Uri.parse('$brokerUrl/upload-index'),
);
req.headers['Authorization'] = 'Bearer $token';
req.fields['filename'] = p.basename(file.path);
req.fields['size_bytes'] = (await file.length()).toString();
req.fields['content_type'] = 'image/jpeg';
@@ -317,7 +421,18 @@ class _UploadPageState extends State<UploadPage> {
});
try {
await _refreshAgentStatus();
final res = await http.get(Uri.parse('$brokerUrl/photos'));
final token = await _getValidAccessToken();
if (token == null) {
setState(() {
_galleryLoading = false;
_galleryError = 'Sign in is required to load gallery.';
});
return;
}
final res = await http.get(
Uri.parse('$brokerUrl/photos'),
headers: {'Authorization': 'Bearer $token'},
);
if (res.statusCode != 200) {
setState(() {
_galleryLoading = false;
@@ -329,7 +444,7 @@ class _UploadPageState extends State<UploadPage> {
final body = jsonDecode(res.body);
final items = _extractPhotoItems(body);
final photos = items
.map(_toGalleryPhoto)
.map((x) => _toGalleryPhoto(x, token))
.whereType<GalleryPhoto>()
.toList(growable: false);
@@ -361,7 +476,17 @@ class _UploadPageState extends State<UploadPage> {
Future<void> _refreshAgentStatus() async {
try {
final res = await http.get(Uri.parse('$brokerUrl/'));
final token = await _getValidAccessToken();
if (token == null) {
if (mounted) {
setState(() => _agentConnected = false);
}
return;
}
final res = await http.get(
Uri.parse('$brokerUrl/agent/hints'),
headers: {'Authorization': 'Bearer $token'},
);
if (res.statusCode != 200) {
if (mounted) {
setState(() => _agentConnected = false);
@@ -392,11 +517,11 @@ class _UploadPageState extends State<UploadPage> {
.toList(growable: false);
}
GalleryPhoto? _toGalleryPhoto(Map<String, dynamic> item) {
GalleryPhoto? _toGalleryPhoto(Map<String, dynamic> item, String accessToken) {
final thumbRaw = item['thumbnail_url'];
if (thumbRaw is! String) return null;
final thumb = _resolveUrl(thumbRaw);
final thumb = _resolveUrl(thumbRaw, accessToken);
if (thumb.isEmpty) return null;
final rawFilename = item['filename'];
@@ -407,7 +532,7 @@ class _UploadPageState extends State<UploadPage> {
String full;
if (explicitFull is String && explicitFull.trim().isNotEmpty) {
full = _resolveUrl(explicitFull);
full = _resolveUrl(explicitFull, accessToken);
} else if (filename != null) {
final encoded = Uri.encodeComponent(filename);
full = '$brokerUrl/photo/$encoded';
@@ -424,12 +549,15 @@ class _UploadPageState extends State<UploadPage> {
);
}
String _resolveUrl(String value) {
String _resolveUrl(String value, String accessToken) {
final trimmed = value.trim();
if (trimmed.isEmpty) return '';
final uri = Uri.tryParse(trimmed);
if (uri != null && uri.hasScheme && uri.hasAuthority) return trimmed;
return Uri.parse(brokerUrl).resolve(trimmed).toString();
final resolved = (uri != null && uri.hasScheme && uri.hasAuthority)
? trimmed
: Uri.parse(brokerUrl).resolve(trimmed).toString();
final sep = resolved.contains('?') ? '&' : '?';
return '$resolved${sep}access_token=${Uri.encodeQueryComponent(accessToken)}';
}
Future<void> _openFullPhoto(GalleryPhoto photo) async {
@@ -580,6 +708,42 @@ class _UploadPageState extends State<UploadPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_accessToken.isEmpty
? 'Broker session: not signed in'
: 'Broker session: signed in',
),
const SizedBox(height: 8),
TextField(
controller: _googleIdTokenController,
decoration: const InputDecoration(
labelText: 'Google ID token',
helperText: 'Paste Google ID token, then sign in.',
),
obscureText: true,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () async {
final ok = await _signInWithGoogleIdToken();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok ? 'Broker sign-in successful.' : 'Broker sign-in failed.',
),
),
);
if (ok) {
await _loadGallery();
}
},
child: const Text('Sign In'),
),
),
const Divider(height: 20),
const Text(
'Direct upload is automatic using broker-provided agent LAN hints. '
'If direct fails, upload falls back to broker.',

2
memory

Submodule memory updated: f4ddac7a07...ab50b9a0f5