Security: Cloud-Auth auf Bearer-JWT umgestellt (IDOR-Luecke geschlossen)

- cloud_service: echter Login gegen baka-auth, Token statt X-API-Key/X-User
- app_config: hartcodierten API-Key-Default entfernt
- download_service + melo_logger: Bearer-Token statt X-API-Key
- navidrome: Passwort in flutter_secure_storage (Keychain/Keystore)
- song: token-haltige stream_url wird nicht mehr in SQLite persistiert
- cloud_screen: Pfad-Traversal beim Download-Dateinamen gefixt (p.basename)
- home_screen: Login-Dialog mit Passwort-Feld, Auto-Sync nutzt restoreLogin
This commit is contained in:
Hermes (Server)
2026-07-31 14:31:12 +02:00
parent 3e9389ab59
commit 9a880b0f21
17 changed files with 1552 additions and 181 deletions
+208
View File
@@ -0,0 +1,208 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import '../services/melo_logger.dart';
/// Cloud-Sync Service für Melo Registry.
/// Auth: Bearer-JWT vom Baka-Auth-Server (Login mit Nutzername + Passwort).
/// Der alte X-API-Key/X-User-Mechanismus wurde entfernt (IDOR-Lücke).
class CloudService {
static String get _base => AppConfig.cloudUrl;
static String get _authBase => AppConfig.authUrl;
String _user = '';
String _token = '';
String get user => _user;
String get token => _token;
bool get istAngemeldet => _token.isNotEmpty;
/// Echter Login gegen den Baka-Auth-Server.
/// Der Token wird gespeichert und bei allen Cloud-Calls als
/// Authorization: Bearer `token` mitgeschickt.
Future<bool> login(String user, String pass) async {
try {
final r = await http
.post(Uri.parse('$_authBase/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': user, 'password': pass}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
if (d['status'] == 'ok' && d['token'] != null) {
_user = d['username'] as String? ?? user;
_token = d['token'] as String;
await _speichereToken();
MeloLogger.cloudToken = _token;
return true;
}
}
} catch (_) {}
return false;
}
/// Stellt gespeicherten Token wieder her (Auto-Login nach App-Start).
Future<bool> restoreLogin() async {
final prefs = await SharedPreferences.getInstance();
final t = prefs.getString('melo_cloud_token') ?? '';
if (t.isEmpty) return false;
_token = t;
_user = prefs.getString('melo_cloud_user') ?? '';
MeloLogger.cloudToken = t;
return true;
}
Future<void> logout() async {
_token = '';
_user = '';
MeloLogger.cloudToken = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove('melo_cloud_token');
await prefs.remove('melo_cloud_user');
}
Future<void> _speichereToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('melo_cloud_token', _token);
await prefs.setString('melo_cloud_user', _user);
}
Map<String, String> get _authHeader => {
if (_token.isNotEmpty) 'Authorization': 'Bearer $_token',
};
Future<Map?> status() => _get('/api/cloud/status');
Future<List<Map>> listSongs() async {
final r = await _get('/api/cloud/list');
return List<Map>.from(r?['songs'] ?? []);
}
Future<String?> upload(String filepath, String filename) async {
try {
final req = http.MultipartRequest('POST', Uri.parse('$_base/api/cloud/upload'));
req.headers.addAll(_authHeader);
req.files.add(await http.MultipartFile.fromPath('file', filepath,
filename: filename));
final resp = await req.send().timeout(const Duration(seconds: 120));
final body = jsonDecode(await resp.stream.bytesToString());
return body['song_id'] as String?;
} catch (e) {
MeloLogger().fehler('cloud_upload', e);
return null;
}
}
Future<bool> download(String songId, String destPath) async {
try {
final r = await http
.get(Uri.parse('$_base/api/cloud/download/$songId'),
headers: _authHeader)
.timeout(const Duration(seconds: 120));
if (r.statusCode == 200) {
final file = File(destPath);
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
await file.writeAsBytes(r.bodyBytes);
return true;
}
return false;
} catch (e) {
MeloLogger().fehler('cloud_download', e);
return false;
}
}
Future<bool> delete(String songId) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/delete'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
Future<Map?> _get(String path) async {
try {
final r = await http
.get(Uri.parse('$_base$path'), headers: _authHeader)
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
} catch (_) {}
return null;
}
Future<String?> share(List<String> songIds) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/share'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'song_ids': songIds}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
return d['code'] as String?;
}
} catch (_) {}
return null;
}
Future<Map?> importCode(String code) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/import'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'code': code}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
} catch (_) {}
return null;
}
Future<List<Map>> syncChanges(String since) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/sync'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'since': since}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
return List<Map>.from(d['changes'] ?? []);
}
} catch (_) {}
return [];
}
Future<List<Map>> globalList() async {
try {
final r = await http
.get(Uri.parse('$_base/api/cloud/global'), headers: _authHeader)
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
return List<Map>.from(jsonDecode(r.body)['songs'] ?? []);
}
} catch (_) {}
return [];
}
Future<bool> toggleGlobal(String songId) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/toggle-global'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
}
+13 -2
View File
@@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart';
import '../models/song.dart';
import '../database/db_helper.dart';
import 'melo_logger.dart';
import 'cloud_service.dart';
/// Download-Service: Lädt YouTube-Audio über den yt-proxy herunter.
/// Nutzt ChangeNotifier für UI-Updates via ListenableBuilder.
@@ -16,8 +17,10 @@ class DownloadService extends ChangeNotifier {
DownloadService._();
static const String _proxyBasisUrl = 'https://yt.baka-net.de';
static const String _apiKey = 'melo-yT9xK7pQ3nR5vW2b';
static const Map<String, String> _authHeader = {'X-API-Key': _apiKey};
static Map<String, String> get _authHeader {
final t = CloudService().token;
return {if (t.isNotEmpty) 'Authorization': 'Bearer $t'};
}
final DbHelper _db = DbHelper();
@@ -146,6 +149,14 @@ class DownloadService extends ChangeNotifier {
String? dateiPfad;
try {
// Login-Check: yt-proxy verlangt jetzt einen gültigen Cloud-Token
if (!CloudService().istAngemeldet) {
_fehler = 'Bitte zuerst in der Cloud anmelden (Cloud-Tab)';
_ladt = false;
notifyListeners();
return null;
}
// URL-Validierung
if (!url.contains('youtube.com') && !url.contains('youtu.be')) {
_fehler = 'Keine gültige YouTube-URL';
+10 -2
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
/// Umfassendes Log-System für die Melo App.
/// Zeichnet ALLES auf: Aktionen, Fehler, Netzwerk, Performance.
@@ -12,6 +13,9 @@ class MeloLogger {
factory MeloLogger() => _instanz;
MeloLogger._();
/// Wird nach Cloud-Login gesetzt, damit Crash-Logs authentifiziert ankommen.
static String? cloudToken;
bool _initialisiert = false;
String _sessionId = '';
String _version = '2.7';
@@ -19,7 +23,7 @@ class MeloLogger {
int _logId = 0;
Timer? _flushTimer;
static const String _serverUrl = 'http://159.195.51.99:8991/api/log';
String get _serverUrl => '${AppConfig.logUrl}/api/log';
void init(String version) {
if (_initialisiert) return;
@@ -94,6 +98,7 @@ class MeloLogger {
Future<void> _senden() async {
if (_eintraege.isEmpty) return;
if (!AppConfig.sendeDiagnosedaten) return;
final batch = _eintraege.toList();
_eintraege.clear();
@@ -101,7 +106,10 @@ class MeloLogger {
try {
await http.post(
Uri.parse(_serverUrl),
headers: {'Content-Type': 'application/json'},
headers: {
'Content-Type': 'application/json',
if (cloudToken != null) 'Authorization': 'Bearer $cloudToken',
},
body: jsonEncode({
'typ': 'log_batch',
'session': _sessionId,
+29
View File
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../models/song.dart';
import '../database/db_helper.dart';
@@ -63,6 +64,10 @@ class SubsonicAlbum {
class NavidromeService {
final DbHelper _db = DbHelper();
/// Passwort & Zugangsdaten liegen verschlüsselt im Keychain/Keystore
/// (flutter_secure_storage) statt im Klartext in SharedPreferences.
static const _secure = FlutterSecureStorage();
String _serverUrl = '';
String _user = '';
String _password = '';
@@ -80,6 +85,30 @@ class NavidromeService {
_token = md5.convert(utf8.encode(_password + _salt)).toString();
}
Future<void> ladeGespeicherteZugangsdaten() async {
try {
final url = await _secure.read(key: 'navidrome_url');
final user = await _secure.read(key: 'navidrome_user');
final pass = await _secure.read(key: 'navidrome_pass');
if (url != null && user != null && pass != null && url.isNotEmpty) {
setCredentials(url, user, pass);
}
} catch (e) {
debugPrint('Fehler beim Laden der Navidrome-Zugangsdaten: $e');
}
}
Future<void> speichereZugangsdaten(String url, String user, String password) async {
setCredentials(url, user, password);
try {
await _secure.write(key: 'navidrome_url', value: url);
await _secure.write(key: 'navidrome_user', value: user);
await _secure.write(key: 'navidrome_pass', value: password);
} catch (e) {
debugPrint('Fehler beim Speichern der Navidrome-Zugangsdaten: $e');
}
}
bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty;
Uri _uri(String endpoint, [Map<String, String>? extra]) {