475 lines
12 KiB
Dart
475 lines
12 KiB
Dart
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 {
|
|
final body = await req.asJson();
|
|
final username = (body['username'] as String?)?.trim();
|
|
final password = body['password'] as String?;
|
|
|
|
if (username == null || username.isEmpty) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('Username required'),
|
|
);
|
|
}
|
|
|
|
if (password == null || password.length < 6) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('Password must be at least 6 characters'),
|
|
);
|
|
}
|
|
|
|
// 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();
|
|
|
|
if (existing != null) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('Username already taken'),
|
|
);
|
|
}
|
|
|
|
// Generate keypair for identity
|
|
final keyPair = CryptoService.generateKeyPair();
|
|
|
|
// Encrypt private key with password
|
|
final encryptedKey = CryptoService.encryptPrivateKey(
|
|
keyPair.privateKey,
|
|
password,
|
|
);
|
|
|
|
// Hash password
|
|
final passwordHash = AuthService.hashPassword(password);
|
|
|
|
// Create user with userId format: "username@this.server"
|
|
final userId = '${username}@${Config.serverHost}';
|
|
|
|
final user = ChatUser(
|
|
userId: userId,
|
|
username: username,
|
|
publicKey: keyPair.publicKey,
|
|
isLocal: true,
|
|
passwordHash: passwordHash,
|
|
encryptedPrivateKey: encryptedKey.encryptedData,
|
|
keyPairSalt: encryptedKey.salt,
|
|
roles: ['member'],
|
|
);
|
|
|
|
final localId = userBox.put(user);
|
|
|
|
// Generate token
|
|
final token = AuthService.generateToken(userId, username);
|
|
|
|
return Response.ok(
|
|
body: {
|
|
'token': token,
|
|
'userId': userId,
|
|
'username': username,
|
|
'publicKey': keyPair.publicKey,
|
|
'encryptedPrivateKey': encryptedKey.encryptedData,
|
|
'keyPairSalt': encryptedKey.salt,
|
|
'localId': localId,
|
|
}.toBody,
|
|
);
|
|
}
|
|
|
|
/// Login on HOME server
|
|
/// Returns token + encrypted keypair for device sync
|
|
Future<Response> loginHandler(Request req) async {
|
|
final body = await req.asJson();
|
|
final username = body['username'] as String?;
|
|
final password = body['password'] as String?;
|
|
|
|
if (username == null || password == null) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('Username and password required'),
|
|
);
|
|
}
|
|
|
|
final query = userBox
|
|
.query(
|
|
ChatUser_.username.equals(username) & ChatUser_.isLocal.equals(true),
|
|
)
|
|
.build();
|
|
final user = query.findFirst();
|
|
query.close();
|
|
|
|
if (user == null) {
|
|
return Response.unauthorized(
|
|
body: Body.fromString('Invalid credentials'),
|
|
);
|
|
}
|
|
|
|
// 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.'),
|
|
);
|
|
}
|
|
|
|
// Verify password
|
|
if (!AuthService.verifyPassword(password, user.passwordHash!)) {
|
|
return Response.unauthorized(
|
|
body: Body.fromString('Invalid credentials'),
|
|
);
|
|
}
|
|
|
|
// Generate token
|
|
final token = AuthService.generateToken(user.userId, user.username);
|
|
|
|
return Response.ok(
|
|
body: {
|
|
'token': token,
|
|
'userId': user.userId,
|
|
'username': user.username,
|
|
'displayName': user.displayName,
|
|
'avatarUrl': user.avatarUrl,
|
|
'publicKey': user.publicKey,
|
|
'encryptedPrivateKey': user.encryptedPrivateKey,
|
|
'keyPairSalt': user.keyPairSalt,
|
|
'localId': user.id,
|
|
}.toBody,
|
|
);
|
|
}
|
|
|
|
/// Join a GUEST server using signature verification
|
|
/// No password required - proves identity via private key signature
|
|
Future<Response> joinServerHandler(Request req) async {
|
|
final body = await req.asJson();
|
|
final userId = body['userId'] as String?; // "alice@homeserver.com"
|
|
final homeServer = body['homeServer'] as String?; // "https://homeserver.com"
|
|
final timestamp = body['timestamp'] as int?;
|
|
final signature = body['signature'] as String?;
|
|
|
|
// Validate required fields
|
|
if (userId == null || timestamp == null || signature == null) {
|
|
return Response.badRequest(
|
|
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'),
|
|
);
|
|
}
|
|
|
|
// Check timestamp is recent (within 5 minutes)
|
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
if ((now - timestamp).abs() > 300) {
|
|
return Response.badRequest(
|
|
body: Body.fromString('Timestamp expired. Please sync your clock.'),
|
|
);
|
|
}
|
|
|
|
// 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(ChatUser_.userId.equals(userId)).build();
|
|
final existingUser = existingQuery.findFirst();
|
|
existingQuery.close();
|
|
|
|
if (existingUser != null) {
|
|
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(
|
|
body: {
|
|
'token': token,
|
|
'userId': existingUser.userId,
|
|
'username': existingUser.username,
|
|
'localId': existingUser.id,
|
|
'alreadyJoined': true,
|
|
}.toBody,
|
|
);
|
|
}
|
|
|
|
// Create local shadow account
|
|
final user = ChatUser(
|
|
userId: userId,
|
|
username: identity.username,
|
|
displayName: identity.displayName,
|
|
avatarUrl: identity.avatarUrl,
|
|
bio: identity.bio,
|
|
publicKey: identity.publicKey,
|
|
isLocal: false,
|
|
homeServer: resolvedHomeServer,
|
|
roles: ['member'],
|
|
lastSyncedAt: DateTime.now(),
|
|
);
|
|
|
|
final localId = userBox.put(user);
|
|
|
|
// Generate local token
|
|
final token = AuthService.generateToken(userId, user.username);
|
|
|
|
return Response.ok(
|
|
body: {
|
|
'token': token,
|
|
'userId': userId,
|
|
'username': user.username,
|
|
'localId': localId,
|
|
'alreadyJoined': false,
|
|
}.toBody,
|
|
);
|
|
}
|
|
|
|
/// 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 {
|
|
final baseUrl = homeServer.replaceAll(RegExp(r'/$'), '');
|
|
final endpoint = Uri.parse(
|
|
'$baseUrl/.well-known/users/${Uri.encodeComponent(username)}');
|
|
|
|
final request = await client.getUrl(endpoint);
|
|
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
|
|
final response = await request.close();
|
|
|
|
if (response.statusCode != HttpStatus.ok) {
|
|
return null;
|
|
}
|
|
|
|
final responseBody = await utf8.decodeStream(response);
|
|
final dynamic decoded = jsonDecode(responseBody);
|
|
if (decoded is! Map) {
|
|
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];
|
|
|
|
if (userId == null) {
|
|
return Response.unauthorized(
|
|
body: Body.fromString('Not authenticated'),
|
|
);
|
|
}
|
|
|
|
final query = userBox.query(ChatUser_.userId.equals(userId)).build();
|
|
final user = query.findFirst();
|
|
query.close();
|
|
|
|
if (user == null) {
|
|
return Response.notFound(
|
|
body: Body.fromString('User not found'),
|
|
);
|
|
}
|
|
|
|
return Response.ok(
|
|
body: {
|
|
'userId': user.userId,
|
|
'username': user.username,
|
|
'displayName': user.displayName,
|
|
'avatarUrl': user.avatarUrl,
|
|
'bio': user.bio,
|
|
'isLocal': user.isLocal,
|
|
'homeServer': user.homeServer,
|
|
'roles': user.roles,
|
|
'createdAt': user.createdAt.toIso8601String(),
|
|
}.toBody,
|
|
);
|
|
}
|