6 phút đọc

App di động lưu token đăng nhập ở đâu cho an toàn

AsyncStorage và SharedPreferences là chữ thường, ai mở được file là đọc được token. Cách lưu token bằng Keychain và Keystore, chặn sao lưu, chỉ dùng HTTPS, khi nào nên ghim chứng chỉ, che màn hình trong trình chuyển app và giữ deep link không tự làm việc nguy hiểm.

Nhờ AI viết phần đăng nhập cho app React Native, bạn rất hay nhận được dòng này:

await AsyncStorage.setItem("token", accessToken);

Nó chạy, đăng nhập giữ được qua mỗi lần mở app. Nhưng AsyncStorage lưu dữ liệu dưới dạng chữ thường không mã hóa. Trên Android, đó là một file SQLite trong thư mục của app. Máy đã root, bản sao lưu bị trích xuất, hay một công cụ debug cắm vào máy là đọc được ngay. Với Flutter, SharedPreferences cũng vậy.

Token đăng nhập trên điện thoại giống chìa khóa nhà để dưới tấm thảm chùi chân: tiện, ai đi ngang cũng không thấy, cho tới khi có người lật tấm thảm lên.

Linh vật robot nhấc góc tấm thảm chùi chân, bên dưới có chiếc chìa khóa sáng, cạnh đó là chiếc điện thoại nhỏ phát ra ánh sáng

Chỗ cất đúng: Keychain và Keystore

iOS có Keychain, Android có Keystore. Cả hai do hệ điều hành quản lý, khóa mã hóa nằm trong phần cứng bảo mật trên máy đời mới, app khác không đọc được.

React Native với Expo dùng expo-secure-store. Theo tài liệu của Expo, trên iOS nó lưu vào Keychain, trên Android lưu vào SharedPreferences đã mã hóa bằng khóa trong Android Keystore:

import * as SecureStore from "expo-secure-store";

await SecureStore.setItemAsync("refresh_token", refreshToken, {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});

const token = await SecureStore.getItemAsync("refresh_token");
await SecureStore.deleteItemAsync("refresh_token"); // khi đăng xuất

WHEN_UNLOCKED_THIS_DEVICE_ONLY nghĩa là chỉ đọc được khi máy đã mở khóa, và không theo bản sao lưu sang máy khác. Expo cũng ghi chú một số bản iOS cũ từ chối giá trị dài hơn khoảng 2048 byte, nên lưu token, đừng lưu cả object hồ sơ người dùng.

React Native không dùng Expo có react-native-keychain. Flutter có flutter_secure_storage, cũng dựa trên Keychain và Keystore.

Với thao tác nhạy cảm (chuyển tiền, xem thông tin y tế), expo-secure-store có tùy chọn requireAuthentication: true bắt người dùng xác thực vân tay hay Face ID mỗi lần đọc. Lưu ý tài liệu của Expo: thêm vân tay mới hay đổi khuôn mặt là khóa bị vô hiệu, lần đọc sau trả về null, nên app phải xử lý được trường hợp đó bằng cách đăng nhập lại.

Lưu cái gì, lưu bao lâu

  • Access token sống ngắn (vài phút tới một giờ), giữ trong bộ nhớ là đủ.
  • Refresh token sống lâu hơn, đây là thứ cần cất vào Keychain hay Keystore.
  • Đăng xuất thì xóa token trên máy và thu hồi refresh token trên server. Chỉ xóa trên máy thì bản đã bị chép vẫn dùng được.
  • Không lưu mật khẩu người dùng trên máy, kể cả trong Keychain. Đã có refresh token thì không cần.

Tắt sao lưu cho token

Android Auto Backup có thể sao lưu dữ liệu app lên Google Drive và khôi phục sang máy mới. Theo tài liệu của Expo, nếu bạn không có cấu hình sao lưu riêng thì config plugin của expo-secure-store tự loại thư mục của nó khỏi bản sao lưu (tùy chọn configureAndroidBackup, mặc định bật). Nếu bạn đã tự viết backup_rules.xml hay data_extraction_rules.xml, phải tự thêm dòng loại trừ, và tắt tùy chọn đó đi.

Nếu app cũ đang lưu token trong AsyncStorage, nhớ rằng file đó đang được sao lưu. Chuyển sang SecureStore xong thì xóa khóa cũ khỏi AsyncStorage.

Trên iOS, các mức ..._THIS_DEVICE_ONLY của Keychain giữ token không theo bản sao lưu sang máy khác.

Chỉ nói chuyện qua HTTPS

Android từ bản 9 (API 28) mặc định chặn kết nối HTTP không mã hóa, iOS có App Transport Security cũng chặn mặc định. Lỗi hay gặp là lúc dev, AI thêm dòng mở khóa cho tiện rồi quên gỡ:

<!-- AndroidManifest.xml: đừng để dòng này lên bản phát hành -->
<application android:usesCleartextTraffic="true" ...>
<!-- Info.plist: tương tự -->
<key>NSAllowsArbitraryLoads</key><true/>

Nếu dev cần gọi http://192.168.1.10:3000, chỉ mở cho đúng địa chỉ đó trong cấu hình debug, không mở toàn bộ.

Có nên ghim chứng chỉ

Ghim chứng chỉ (certificate pinning) là app chỉ tin một số khóa công khai cụ thể của server, nên kể cả khi máy người dùng bị cài một chứng chỉ gốc lạ thì kết nối vẫn bị từ chối. Nghe chắc, nhưng tài liệu của Android cảnh báo: phải luôn có một pin dự phòng, nếu không thì khi đổi khóa hay đổi nhà cấp chứng chỉ, app sẽ mất kết nối cho tới khi người dùng cập nhật bản mới.

Với chứng chỉ ngày càng ngắn hạn, và nhà cung cấp hạ tầng có thể đổi khóa bất cứ lúc nào, phần lớn app nhỏ không nên ghim. Ứng dụng tài chính, y tế, hay app có yêu cầu riêng thì có thể cân nhắc, ghim vào khóa của CA trung gian kèm khóa dự phòng, và có kế hoạch cập nhật. Trên Android làm bằng pin-set trong Network Security Config.

Che màn hình trong trình chuyển app

Khi người dùng vuốt ra màn hình đa nhiệm, hệ điều hành chụp lại màn hình app để làm ảnh thu nhỏ. Màn hình đang hiện số dư tài khoản, mã OTP hay tin nhắn riêng tư sẽ nằm trong ảnh đó.

  • Android: đặt cờ FLAG_SECURE cho cửa sổ để chặn chụp màn hình, quay màn hình và làm trống ảnh thu nhỏ trong Recents. Với React Native có các thư viện bọc sẵn, hoặc expo-screen-capture (preventScreenCaptureAsync).
  • iOS: không có cờ tương đương. Cách thường dùng là khi app sắp vào nền (AppState chuyển sang inactive), phủ một màn che lên nội dung.
const [hidden, setHidden] = useState(false);
useEffect(() => {
  const sub = AppState.addEventListener("change", (s) => setHidden(s !== "active"));
  return () => sub.remove();
}, []);
return <>{children}{hidden && <View style={StyleSheet.absoluteFill}><SplashCover /></View>}</>;

Chỉ làm cho màn hình có thông tin nhạy cảm, đừng chặn chụp màn hình toàn app nếu không cần, người dùng sẽ khó chịu.

Deep link dạng myapp://... là scheme tự đặt. Tài liệu của Android nói rõ: app nào cũng khai báo được cùng scheme đó, và người dùng có thể được hỏi chọn app để mở. Nghĩa là link của bạn có thể bị app khác nhận, và app của bạn có thể nhận link do bất kỳ trang web, tin nhắn nào gửi tới.

Vài nguyên tắc:

  • Link đăng nhập, đặt lại mật khẩu, callback OAuth dùng Android App Links và iOS Universal Links (link https:// đã xác minh với tên miền của bạn), không dùng scheme tự đặt.
  • Deep link chỉ mở màn hình, không tự thực hiện hành động. myapp://transfer?to=123&amount=500000 phải mở màn xác nhận, người dùng bấm thì mới chuyển.
  • Không nhận token qua tham số deep link rồi lưu ngay. Với OAuth, dùng PKCE để mã nhận về vô dụng nếu bị app khác chặn.
  • Kiểm và giới hạn mọi tham số trong link như với input từ người lạ.

Kiểm tra app của bạn trong vài phút

  1. Tìm trong code: grep -rn "AsyncStorage.setItem\|SharedPreferences\|localStorage" src lib app và xem có dòng nào lưu token không.
  2. Tìm cấu hình mở HTTP: grep -rn "usesCleartextTraffic\|NSAllowsArbitraryLoads\|cleartextTrafficPermitted" android ios.
  3. Mở app, vào màn hình có thông tin nhạy cảm, vuốt ra màn hình đa nhiệm. Đọc được nội dung trong ảnh thu nhỏ là cần che.
  4. Liệt kê deep link của app (trong app.json, AndroidManifest.xml, Info.plist). Thử mở từng link từ trình duyệt bằng tham số lạ, xem app có tự làm gì mà không hỏi không.
  5. Đăng xuất, rồi gọi API refresh bằng refresh token cũ (lấy từ bản build debug của chính bạn). Còn ra token mới là server chưa thu hồi.

App di động của bạn đang lưu token bằng gì? Vào cộng đồng Vibe Code & Bảo Mật kể cho mọi người nghe nhé.

Nguồn

Chia sẻ lên Facebook

Bình luận

Đang tải bình luận…