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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
Reference in New Issue
Block a user