Implement chat server auth, models, handlers, and docs context

This commit is contained in:
Foxyz committed 2026-02-20 03:44:02 +03:00
1 parent 23602efd5f
commit f6b27565cd
16 files changed
+1285 -152

No files matched your search

+232 -52
View File
@@ -1,11 +1,44 @@
import 'dart:convert';
import 'dart:io';
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../../config/config.dart';
import '../auth/auth_middleware.dart';
import '../auth/crypto_service.dart';
import '../state/app_state.dart';
import '../utils/extensions.dart';
class RemoteIdentity {
final String userId;
final String username;
final String publicKey;
final String? displayName;
final String? avatarUrl;
final String? bio;
RemoteIdentity({
required this.userId,
required this.username,
required this.publicKey,
this.displayName,
this.avatarUrl,
this.bio,
});
}
class ParsedUserId {
final String username;
final String host;
ParsedUserId({
required this.username,
required this.host,
});
}
/// Register on HOME server
/// Creates user with keypair for identity
Future<Response> registerHandler(Request req) async {
@@ -25,8 +58,12 @@ Future<Response> registerHandler(Request req) async {
);
}
// Check if username exists
final query = userBox.query(User_.username.equals(username)).build();
// Check if username exists on this home server
final query = userBox
.query(
ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true),
)
.build();
final existing = query.findFirst();
query.close();
@@ -51,7 +88,7 @@ Future<Response> registerHandler(Request req) async {
// Create user with userId format: "username@this.server"
final userId = '${username}@${Config.serverHost}';
final user = User(
final user = ChatUser(
userId: userId,
username: username,
publicKey: keyPair.publicKey,
@@ -93,7 +130,11 @@ Future<Response> loginHandler(Request req) async {
);
}
final query = userBox.query(User_.username.equals(username)).build();
final query = userBox
.query(
ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true),
)
.build();
final user = query.findFirst();
query.close();
@@ -106,7 +147,8 @@ Future<Response> loginHandler(Request req) async {
// Check if this is actually their home server
if (!user.isLocal) {
return Response.badRequest(
body: Body.fromString('This is not your home server. Login there instead.'),
body:
Body.fromString('This is not your home server. Login there instead.'),
);
}
@@ -143,12 +185,33 @@ Future<Response> joinServerHandler(Request req) async {
final homeServer = body['homeServer'] as String?; // "https://homeserver.com"
final timestamp = body['timestamp'] as int?;
final signature = body['signature'] as String?;
final username = body['username'] as String?;
// Validate required fields
if (userId == null || homeServer == null || timestamp == null || signature == null) {
if (userId == null || timestamp == null || signature == null) {
return Response.badRequest(
body: Body.fromString('userId, homeServer, timestamp, and signature required'),
body: Body.fromString(
'userId, timestamp, and signature required (homeServer optional)'),
);
}
final parsedUser = parseUserId(userId);
if (parsedUser == null) {
return Response.badRequest(
body: Body.fromString('Invalid userId format. Expected username@host'),
);
}
final resolvedHomeServer = normalizeHomeServer(homeServer ?? parsedUser.host);
if (resolvedHomeServer == null) {
return Response.badRequest(
body: Body.fromString('Invalid homeServer URL'),
);
}
if (!homeServerMatchesUserIdHost(resolvedHomeServer, parsedUser.host)) {
return Response.badRequest(
body: Body.fromString('homeServer does not match userId host'),
);
}
@@ -160,14 +223,54 @@ Future<Response> joinServerHandler(Request req) async {
);
}
// Fetch current identity from home server
final identity = await fetchIdentityFromHomeServer(
resolvedHomeServer,
parsedUser.username,
);
if (identity == null) {
return Response.badRequest(
body: Body.fromString('Could not verify identity with home server'),
);
}
// Ensure fetched identity matches what caller claims
if (identity.userId != userId || identity.username != parsedUser.username) {
return Response.forbidden(
body: Body.fromString('Identity mismatch from home server'),
);
}
// Verify signature
// Signature is over: "{userId}:{timestamp}"
final messageToVerify = '$userId:$timestamp';
final isValid =
CryptoService.verify(messageToVerify, signature, identity.publicKey);
if (!isValid) {
return Response.forbidden(
body: Body.fromString('Invalid signature'),
);
}
// Check if user already exists on this server
final existingQuery = userBox.query(User_.userId.equals(userId)).build();
final existingQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
final existingUser = existingQuery.findFirst();
existingQuery.close();
if (existingUser != null) {
// User already joined - just return a new token
final token = AuthService.generateToken(existingUser.userId, existingUser.username);
existingUser.username = identity.username;
existingUser.displayName = identity.displayName;
existingUser.avatarUrl = identity.avatarUrl;
existingUser.bio = identity.bio;
existingUser.publicKey = identity.publicKey;
existingUser.homeServer = resolvedHomeServer;
existingUser.lastSyncedAt = DateTime.now();
userBox.put(existingUser);
final token =
AuthService.generateToken(existingUser.userId, existingUser.username);
userLocalIdProperty[req] = existingUser.id;
return Response.ok(
@@ -181,34 +284,18 @@ Future<Response> joinServerHandler(Request req) async {
);
}
// Fetch public key from home server
final publicKey = await fetchPublicKeyFromHomeServer(homeServer, username ?? userId.split('@').first);
if (publicKey == null) {
return Response.badRequest(
body: Body.fromString('Could not verify identity with home server'),
);
}
// Verify signature
// Signature is over: "{userId}:{timestamp}"
final messageToVerify = '$userId:$timestamp';
final isValid = CryptoService.verify(messageToVerify, signature, publicKey);
if (!isValid) {
return Response.forbidden(
body: Body.fromString('Invalid signature'),
);
}
// Create local shadow account
final user = User(
final user = ChatUser(
userId: userId,
username: username ?? userId.split('@').first,
publicKey: publicKey,
username: identity.username,
displayName: identity.displayName,
avatarUrl: identity.avatarUrl,
bio: identity.bio,
publicKey: identity.publicKey,
isLocal: false,
homeServer: homeServer,
homeServer: resolvedHomeServer,
roles: ['member'],
lastSyncedAt: DateTime.now(),
);
final localId = userBox.put(user);
@@ -227,37 +314,130 @@ Future<Response> joinServerHandler(Request req) async {
);
}
/// Fetch public key from user's home server
Future<String?> fetchPublicKeyFromHomeServer(
/// Fetch public identity from user's home server
Future<RemoteIdentity?> fetchIdentityFromHomeServer(
String homeServer,
String username,
) async {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
try {
// Remove trailing slash
final baseUrl = homeServer.replaceAll(RegExp(r'/$'), '');
final endpoint = Uri.parse(
'$baseUrl/.well-known/users/${Uri.encodeComponent(username)}');
// Use http client to fetch
// Endpoint: GET /.well-known/users/{username}
// Returns: { publicKey, userId, ... }
final request = await client.getUrl(endpoint);
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
final response = await request.close();
// For now, return null to indicate we need http client
// This will be implemented with actual HTTP client
print('Would fetch: $baseUrl/.well-known/users/$username');
if (response.statusCode != HttpStatus.ok) {
return null;
}
// TODO: Implement actual HTTP request
// final response = await http.get(Uri.parse('$baseUrl/.well-known/users/$username'));
// if (response.statusCode == 200) {
// final data = jsonDecode(response.body);
// return data['publicKey'] as String;
// }
final responseBody = await utf8.decodeStream(response);
final dynamic decoded = jsonDecode(responseBody);
if (decoded is! Map) {
return null;
}
return null;
final data = Map<String, dynamic>.from(decoded);
final userId = data['userId'] as String?;
final fetchedUsername = data['username'] as String?;
final publicKey = data['publicKey'] as String?;
if (userId == null || fetchedUsername == null || publicKey == null) {
return null;
}
return RemoteIdentity(
userId: userId,
username: fetchedUsername,
publicKey: publicKey,
displayName: data['displayName'] as String?,
avatarUrl: data['avatarUrl'] as String?,
bio: data['bio'] as String?,
);
} catch (e) {
print('Error fetching public key: $e');
return null;
} finally {
client.close(force: true);
}
}
ParsedUserId? parseUserId(String userId) {
final parts = userId.split('@');
if (parts.length != 2) {
return null;
}
final username = parts[0].trim();
final host = parts[1].trim();
if (username.isEmpty || host.isEmpty) {
return null;
}
return ParsedUserId(username: username, host: host);
}
String? normalizeHomeServer(String homeServerOrHost) {
final trimmed = homeServerOrHost.trim();
if (trimmed.isEmpty) {
return null;
}
final hasScheme = trimmed.contains('://');
final candidate =
hasScheme ? trimmed : '${defaultSchemeForHost(trimmed)}://$trimmed';
final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.isEmpty) {
return null;
}
return uri
.replace(path: '', query: null, fragment: null)
.toString()
.replaceAll(RegExp(r'/$'), '');
}
String defaultSchemeForHost(String host) {
final lowerHost = host.toLowerCase();
if (lowerHost.startsWith('localhost') ||
lowerHost.startsWith('127.') ||
lowerHost.startsWith('[::1]')) {
return 'http';
}
return 'https';
}
bool homeServerMatchesUserIdHost(String homeServerUrl, String userIdHost) {
final uri = Uri.tryParse(homeServerUrl);
if (uri == null || uri.host.isEmpty) {
return false;
}
final normalizedUserIdHost = userIdHost.trim().toLowerCase();
final urlHost = uri.host.toLowerCase();
final urlHostWithPort = uri.hasPort ? '$urlHost:${uri.port}' : urlHost;
if (normalizedUserIdHost == urlHostWithPort) {
return true;
}
if (!uri.hasPort) {
final defaultPort = uri.scheme == 'http' ? 80 : 443;
if (normalizedUserIdHost == '$urlHost:$defaultPort') {
return true;
}
}
return false;
}
/// Get current user's profile
Future<Response> getMeHandler(Request req) async {
final userId = userIdProperty[req];
@@ -268,7 +448,7 @@ Future<Response> getMeHandler(Request req) async {
);
}
final query = userBox.query(User_.userId.equals(userId)).build();
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = query.findFirst();
query.close();