fix: security hardening — Keychain, no hardcoded creds, safe URLs

- Add KeychainService for encrypted token storage (auth, refresh, health JWT, API key)
- Remove hardcoded email/password from HealthAPIService, store in Keychain
- Move all tokens from UserDefaults to Keychain
- API key sent via X-API-Key header instead of URL query parameter
- Replace force unwrap URL(string:)! with guard let + throws
- Fix force unwrap Calendar.date() in HealthKitService
- Mark HealthKitService @MainActor for thread-safe @Published
- Use withTaskGroup for parallel habit log fetching in TrackerView
- Check notification permission before scheduling reminders
- Add input validation (title max 200 chars)
- Add privacy policy and terms links in Settings
- Update CLAUDE.md with security section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 14:11:10 +03:00
parent 28fca1de89
commit 44c759c190
10 changed files with 167 additions and 58 deletions

View File

@@ -3,44 +3,61 @@ import Foundation
class HealthAPIService {
static let shared = HealthAPIService()
let baseURL = "https://health.digital-home.site"
private var cachedToken: String? {
get { UserDefaults.standard.string(forKey: "healthJWTToken") }
set { UserDefaults.standard.set(newValue, forKey: "healthJWTToken") }
get { KeychainService.load(key: KeychainService.healthTokenKey) }
set {
if let v = newValue { KeychainService.save(key: KeychainService.healthTokenKey, value: v) }
else { KeychainService.delete(key: KeychainService.healthTokenKey) }
}
}
// Логин в health сервис (отдельный JWT)
func ensureToken() async throws -> String {
if let t = cachedToken { return t }
return try await refreshToken()
}
func refreshToken() async throws -> String {
// Use credentials from Keychain (set during first login or onboarding)
guard let email = KeychainService.load(key: "health_email"),
let password = KeychainService.load(key: "health_password") else {
throw APIError.unauthorized
}
var req = URLRequest(url: URL(string: "\(baseURL)/api/auth/login")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONEncoder().encode(["email": "daniilklimov25@gmail.com", "password": "cosmo-health-2026"])
req.httpBody = try? JSONEncoder().encode(["email": email, "password": password])
req.timeoutInterval = 15
let (data, _) = try await URLSession.shared.data(for: req)
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw APIError.unauthorized
}
struct LoginResp: Decodable { let token: String }
let resp = try JSONDecoder().decode(LoginResp.self, from: data)
cachedToken = resp.token
return resp.token
}
/// Call once during setup to store health credentials securely
static func configureCredentials(email: String, password: String) {
KeychainService.save(key: "health_email", value: email)
KeychainService.save(key: "health_password", value: password)
}
private func fetch<T: Decodable>(_ path: String) async throws -> T {
let token = try await ensureToken()
var req = URLRequest(url: URL(string: "\(baseURL)\(path)")!)
guard let url = URL(string: "\(baseURL)\(path)") else { throw APIError.networkError("Неверный URL") }
var req = URLRequest(url: url)
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.timeoutInterval = 15
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else { throw APIError.networkError("No response") }
guard let http = response as? HTTPURLResponse else { throw APIError.networkError("Нет ответа") }
if http.statusCode == 401 {
// Token expired, retry once
cachedToken = nil
let newToken = try await refreshToken()
var req2 = URLRequest(url: URL(string: "\(baseURL)\(path)")!)
guard let retryURL = URL(string: "\(baseURL)\(path)") else { throw APIError.networkError("Неверный URL") }
var req2 = URLRequest(url: retryURL)
req2.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
req2.setValue("application/json", forHTTPHeaderField: "Content-Type")
req2.timeoutInterval = 15
@@ -63,7 +80,8 @@ class HealthAPIService {
func getHeatmap(days: Int = 30) async throws -> [HeatmapEntry] {
let token = try await ensureToken()
var req = URLRequest(url: URL(string: "\(baseURL)/api/health/heatmap?days=\(days)")!)
guard let url = URL(string: "\(baseURL)/api/health/heatmap?days=\(days)") else { return [] }
var req = URLRequest(url: url)
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.timeoutInterval = 15
let (data, _) = try await URLSession.shared.data(for: req)
@@ -72,17 +90,4 @@ class HealthAPIService {
let wrapped = try JSONDecoder().decode(HeatmapResponse.self, from: data)
return wrapped.data
}
func sendHealthData(apiKey: String, payload: Data) async throws {
let url = URL(string: "\(baseURL)/api/health?key=\(apiKey)")!
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = payload
req.timeoutInterval = 30
let (_, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw APIError.serverError((response as? HTTPURLResponse)?.statusCode ?? 0, "Ошибка отправки")
}
}
}