Compare commits

..

No commits in common. "main" and "redesign" have entirely different histories.

29 changed files with 360 additions and 1070 deletions

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@ -1,50 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>

View File

@ -1,71 +0,0 @@
# Changelog
Semua perubahan penting pada proyek Aplikasi Absensi Akademik didokumentasikan dalam file ini.
## [Unreleased] - Versi Terbaru (Redesign & Backend Integration)
Perubahan besar-besaran dilakukan pada arsitektur aplikasi, beralih dari aplikasi *single-screen* sederhana menjadi aplikasi manajemen akademik yang komprehensif dengan autentikasi dan validasi keamanan.
### ✨ Fitur Baru (Added)
- **Sistem Autentikasi**:
- Menambahkan layar **Login** dan **Register**.
- Integrasi **JWT Authentication** (penyimpanan token via `SharedPreferences`).
- Manajemen sesi pengguna (Auto-login jika token tersimpan).
- **Manajemen Jadwal & Mata Kuliah**:
- Menambahkan fitur pengambilan data **Jadwal Kuliah** hari ini dari database.
- Dropdown pemilihan mata kuliah saat melakukan absensi.
- **Validasi Keamanan Tingkat Lanjut**:
- **Geofencing**: Validasi lokasi dalam radius 500m dari koordinat kampus.
- **Anti-Fake GPS**: Logika deteksi aplikasi *Mock Location* untuk mencegah kecurangan.
- **Face Detection**: Integrasi **CameraX + ML Kit** (tersirat dalam logika kamera baru) untuk mewajibkan deteksi wajah saat status "HADIR".
- **Fitur Kamera Kustom**:
- Mengganti intent kamera bawaan (`MediaStore`) dengan **CameraX** tertanam dalam UI.
- Fitur overlay kamera untuk panduan posisi wajah/dokumen.
- Dukungan kamera depan (selfie) dan belakang (dokumen).
- **Riwayat & Profil**:
- Menambahkan tab **Riwayat Absensi** untuk melihat log kehadiran dan bukti foto.
- Menambahkan tab **Profil** yang menampilkan data mahasiswa dan fitur Logout.
- **Multi-Status Absensi**:
- Dukungan untuk status **HADIR**, **SAKIT**, dan **IZIN**.
- Logika validasi berbeda untuk setiap status (Sakit/Izin tidak butuh radius lokasi).
### 🎨 Antarmuka & UX (Changed)
- **UI Overhaul (Material3)**:
- Redesign total menggunakan **Jetpack Compose Material3**.
- Penerapan tema identitas kampus (**Gold & Maroon**).
- Penggunaan komponen UI modern: `Card`, `NavigationBar` (Bottom Nav), `Gradient Button`.
- **Navigasi**:
- Implementasi **Bottom Navigation Bar** dengan 4 menu utama (Absensi, Kelas, Riwayat, Profil).
- Transisi antar layar (Login <-> Main <-> Register).
- **Feedback User**:
- Loading indicator saat proses API berjalan.
- Dialog error dan sukses yang lebih informatif dibandingkan `Toast` sederhana.
### ⚙️ Teknis & Backend (Changed)
- **Migrasi API**:
- **Sebelumnya**: Mengirim data hardcoded langsung ke Webhook N8N via `HttpURLConnection`.
- **Sekarang**: Berkomunikasi dengan **Backend Python Flask** (`/api/auth`, `/api/absensi`, `/api/jadwal`). Backend yang kemudian meneruskan data ke N8N.
- **Struktur Kode**:
- Refactoring dari *Single Activity Monolith* menjadi struktur modular.
- Pemisahan logic ke dalam:
- `AppConstants` (Konfigurasi URL & Koordinat).
- `UserPreferences` (Manajemen sesi lokal).
- `Data Classes` (Mahasiswa, Jadwal, Riwayat).
- Helper functions (Bitmap Converter, Distance Calculation).
### 🔥 Dihapus (Removed)
- Menghapus pengiriman data hardcoded (NPM "12345") pada fungsi `kirimKeN8n`.
- Menghapus penggunaan `MediaStore.ACTION_IMAGE_CAPTURE` (Intent kamera eksternal).
- Menghapus tampilan *single-screen* sederhana tanpa navigasi.
---
## [Legacy] - Versi Awal (Sebelum Redesign)
Versi purwarupa (prototype) untuk pengujian fungsionalitas dasar.
### Fitur
- Pengambilan titik koordinat GPS sederhana (`FusedLocationProvider`).
- Pengambilan foto menggunakan aplikasi kamera bawaan HP (`Intent`).
- Pengiriman data dummy langsung ke Webhook N8N.
- Tampilan UI dasar menggunakan `Column` dan `Button` standar.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 715 KiB

196
README.md
View File

@ -1,169 +1,95 @@
# 📱 Aplikasi Absensi Akademik UBHARA Jaya # 📱 Aplikasi Absensi Akademik Berbasis Koordinat dan Foto (Mobile)
[📋 Changelog](./CHANGELOG.md)
## 📌 Deskripsi Proyek ## 📌 Deskripsi Proyek
Proyek ini merupakan **Tugas Project Akhir Mata Kuliah Pemrograman Mobile** yang mengimplementasikan sistem absensi akademik berbasis **Client-Server**. Aplikasi mobile dibangun menggunakan **Kotlin (Jetpack Compose)**, sedangkan sisi server menggunakan **Python Flask** dengan database **MySQL**. Proyek ini merupakan **Tugas Project Akhir Mata Kuliah Pemrograman Mobile** yang bertujuan untuk membangun **aplikasi akademik berbasis mobile** dengan fokus pada **fitur absensi menggunakan data koordinat (GPS) dan pengambilan foto mahasiswa**.
Sistem ini dirancang dengan validasi ketat berlapis menggunakan **Geofencing (GPS)**, **Location Token**, **Device Binding**, dan **Deteksi Wajah (Face Detection)** untuk status kehadiran "Hadir", serta mendukung pengiriman bukti dokumen untuk status "Sakit/Izin". Seluruh data terintegrasi secara real-time ke **N8N Webhook** untuk pelaporan otomatis. Aplikasi ini dirancang untuk meningkatkan **validitas kehadiran mahasiswa**, dengan memastikan bahwa absensi hanya dapat dilakukan apabila mahasiswa:
1. Berada pada **lokasi yang telah ditentukan**, dan
2. Melakukan **pengambilan foto (selfie) secara langsung saat absensi**
--- ---
## 🎯 Tujuan Proyek ## 🎯 Tujuan Proyek
- Mengimplementasikan arsitektur **REST API** antara Android dan Python Flask. - Mengimplementasikan **Location-Based Service (LBS)** pada aplikasi mobile
- Menerapkan **Face Detection** menggunakan CameraX untuk memvalidasi kehadiran fisik mahasiswa. - Mengintegrasikan **kamera perangkat** untuk dokumentasi absensi
- Mencegah kecurangan absensi dengan sistem keamanan berlapis (**8 Security Fix**). - Mencegah kecurangan absensi (titip absen)
- Mengelola status kehadiran otomatis (**Auto-Alfa**) pada server jika mahasiswa lupa absen. - Mengembangkan aplikasi mobile akademik berbasis Android
- Melatih kemampuan Fullstack Mobile Development (Backend, Database, & Mobile UI). - Melatih kemampuan perancangan dan implementasi aplikasi mobile
--- ---
## 🚀 Fitur Utama ## 🚀 Fitur Utama
- 🔐 **Login Pengguna (Mahasiswa)**
### 📱 Sisi Mobile (Android - Jetpack Compose) - 📍 **Pengambilan Koordinat Lokasi (Latitude & Longitude)**
- **Modern UI**: Antarmuka modern menggunakan **Material3** & **Jetpack Compose**. - 🏫 **Validasi Lokasi Absensi (Radius Area)**
- **Smart Attendance**: - 📸 **Pengambilan Foto Mahasiswa Saat Absensi**
- **Hadir**: Wajib berada di radius kampus & wajib deteksi wajah (Real-time). - 🕒 **Pencatatan Waktu Absensi**
- **Sakit/Izin**: Wajib upload foto surat dokter/bukti (Tanpa validasi radius). - 📄 **Riwayat Kehadiran Mahasiswa**
- **Anti-Fraud Berlapis**: - ⚠️ **Notifikasi Absensi Ditolak jika Tidak Valid**
- Deteksi dan penolakan **Fake GPS / Mock Location** di sisi client.
- Validasi koordinat GPS di **sisi server** (tidak bisa di-bypass via Postman).
- **Location Token** — token sekali pakai valid 2 menit, diterbitkan server setelah GPS diverifikasi.
- **Device Binding** — akun terikat ke satu perangkat, token tidak bisa dibagikan ke HP lain.
- **Riwayat & Bukti**: Mahasiswa dapat melihat riwayat kehadiran beserta foto bukti yang tersimpan di server.
- **Profil Mahasiswa**: Menampilkan data akademik dan fitur logout.
### 💻 Sisi Backend (Python Flask)
- **JWT Authentication**: Keamanan berbasis token + device binding untuk setiap request.
- **8 Lapis Keamanan**: Validasi koordinat server-side, foto server-side, rate limiting, kepemilikan jadwal, jam kelas aktif, location token, dan deteksi anomali koordinat.
- **Auto-Alfa Trigger**: Sistem otomatis menandai "TIDAK HADIR" jika jam kuliah berakhir dan mahasiswa belum absen.
- **N8N Integration**: Data absensi yang valid dikirim otomatis ke webhook N8N untuk notifikasi/reporting.
- **Image Handling**: Kompresi foto (Base64) otomatis untuk efisiensi penyimpanan database.
--- ---
## 🗺️ Mekanisme Absensi ## 🗺️ Mekanisme Absensi Berbasis Lokasi dan Foto
1. Mahasiswa melakukan **login**
### 1. Absensi Status "HADIR" 2. Memilih menu **Absensi**
1. Mahasiswa memilih Jadwal Kelas yang aktif. 3. Sistem meminta:
2. Sistem mengecek **GPS** di sisi client: - Izin **akses lokasi**
- Apakah di dalam radius **500m** dari kampus? - Izin **akses kamera**
- Apakah terdeteksi aplikasi **Fake GPS / Mock Location**? 4. Aplikasi mengambil:
3. App meminta **Location Token** ke server `/api/absensi/request-location-token`: - 📍 **Koordinat lokasi mahasiswa**
- Server memvalidasi ulang koordinat (server-side GPS validation). - 📸 **Foto mahasiswa secara real-time**
- Server mendeteksi anomali koordinat (presisi rendah, koordinat copy-paste, luar Indonesia). 5. Sistem melakukan validasi:
- Server menerbitkan **Location Token** sekali pakai, valid **2 menit**. - Lokasi berada dalam **radius absensi**
4. Sistem membuka **Kamera Deteksi Wajah**: - Foto berhasil diambil
- Tombol shutter hanya aktif jika wajah terdeteksi. 6. Jika valid → **Absensi berhasil**
5. Data (Location Token + Foto Wajah) dikirim ke server `/api/absensi/submit`: 7. Jika tidak valid → **Absensi ditolak**
- Server memvalidasi Location Token (keaslian, kepemilikan, masa berlaku, one-time use).
- Server memvalidasi foto (format, ukuran minimum).
- Server memvalidasi kepemilikan jadwal & jam kelas aktif.
### 2. Absensi Status "SAKIT / IZIN"
1. Mahasiswa memilih status Sakit atau Izin.
2. Validasi lokasi dilewati (bisa absen dari rumah/RS).
3. Sistem membuka **Kamera Dokumen** (Tanpa deteksi wajah).
4. Mahasiswa memfoto surat keterangan.
5. Data dikirim ke server.
### 3. Logika Auto-Alfa (Server)
- Setiap kali jadwal dimuat, server mengecek jadwal yang `jam_selesai < jam_sekarang`.
- Jika mahasiswa belum ada record absensi pada jam tersebut, server otomatis menginput status **"TIDAK HADIR"**.
--- ---
## 🔐 Security Architecture ## 📸 Pengambilan Foto Saat Absensi
- Foto diambil menggunakan **kamera depan (selfie)**
Sistem mengimplementasikan **8 lapis keamanan** untuk mencegah kecurangan absensi: - Foto hanya dapat diambil **saat proses absensi**
- Foto disimpan sebagai **bukti kehadiran**
| # | Fix | Deskripsi | - Foto dapat digunakan untuk:
|---|---|---| - Verifikasi manual oleh dosen
| 1 | **Server-side GPS Validation** | Koordinat divalidasi ulang di server, tidak hanya di client Android | - Dokumentasi akademik
| 2 | **Server-side Foto Validation** | Foto divalidasi format (JPG/PNG/WEBP) & ukuran minimum di server |
| 3 | **JWT Device Binding** | Token JWT terikat ke `device_id` unik HP — tidak bisa dibagikan |
| 4 | **Rate Limiting** | Maks 5x login/menit, maks 3x submit absensi/menit per user |
| 5 | **Jadwal Ownership Validation** | Mahasiswa hanya bisa absen di jadwal jurusan & semesternya sendiri |
| 6 | **Jam Kelas Aktif Validation** | Absensi hanya diterima selama jam kelas berlangsung |
| 7 | **Location Token** | Token sekali pakai valid 2 menit — cegah bypass koordinat via Postman/curl |
| 8 | **Anomali Koordinat Detection** | Koordinat 0,0 / terlalu bulat / identik titik kampus / luar Indonesia ditolak |
--- ---
## 🛠️ Teknologi yang Digunakan ## 🛠️ Teknologi yang Digunakan
- **Mobile Platform**: Android (Kotlin) - **Platform** : Android
- **UI Framework**: Jetpack Compose + Material3 - **Bahasa Pemrograman** : Kotlin / Java
- **Camera Engine**: CameraX + ML Kit (Face Analysis) - **Location Service** :
- **Backend Framework**: Python Flask - Google Maps API
- **Database**: MySQL - Fused Location Provider
- **Security**: JWT + Device Binding + Location Token + Rate Limiting - **Camera API** : CameraX / Camera2
- **Integrasi**: N8N Webhook - **Database** : Firebase / SQLite / MySQL
- **Protocol**: HTTP/REST (JSON) - **Storage** : Firebase Storage / Local Storage
- **AI**: Gemini & Claude (Sebagai Tools dalam Membantu Pengembangan) - **IDE** : Android Studio
---
## 📡 API Endpoints
| Method | Endpoint | Auth | Fungsi |
|---|---|---|---|
| GET | `/api/health` | ✗ | Cek server aktif |
| POST | `/api/auth/register` | ✗ | Daftar mahasiswa + device binding |
| POST | `/api/auth/login` | ✗ | Login + verifikasi device |
| GET | `/api/mahasiswa/profile` | ✓ | Profil mahasiswa |
| GET | `/api/jadwal/today` | ✓ | Jadwal hari ini + trigger auto-alfa |
| POST | `/api/absensi/request-location-token` | ✓ | Minta location token (wajib sebelum submit) |
| POST | `/api/absensi/submit` | ✓ | Kirim absensi + location token |
| GET | `/api/absensi/history` | ✓ | Riwayat absensi |
| GET | `/api/absensi/photo/<id>` | ✓ | Ambil foto absensi |
| POST | `/api/admin/reset-device` | Admin Key | Reset device binding mahasiswa |
> Semua endpoint `✓` butuh header: `Authorization: Bearer <token>`
--- ---
## 🔐 Izin Aplikasi (Permissions) ## 🔐 Izin Aplikasi (Permissions)
Aplikasi memerlukan izin berikut: Aplikasi memerlukan izin berikut:
- `INTERNET`: Koneksi ke Server API. - `ACCESS_FINE_LOCATION`
- `ACCESS_FINE_LOCATION`: Validasi koordinat presisi tinggi. - `ACCESS_COARSE_LOCATION`
- `ACCESS_COARSE_LOCATION`: Validasi koordinat jaringan. - `CAMERA`
- `CAMERA`: Pengambilan foto bukti kehadiran & deteksi wajah. - `INTERNET`
- `WRITE_EXTERNAL_STORAGE` (jika diperlukan)
--- ---
## 📂 Mockup ## 📂 Mockup
![mockup](Mockup.png) ![mockup](Mockup.png)
--- ## Catatan:
- Kembangkan project dari starter yang sudah disediakan, tidak membuat dari awal.
- Untuk koordinat bisa ditambah/kurangi angka tertentu agar tidak memunculkan koordinat rumah masing-masing, data awal tetap dari GPS.
## ⚙️ Konfigurasi ## Pengecekan:
- https://ntfy.ubharajaya.ac.id/EAS
- https://docs.google.com/spreadsheets/d/1jH15MfnNgpPGuGeid0hYfY7fFUHCEFbCmg8afTyyLZs/edit?gid=0#gid=0
### Backend ## Webhook:
1. Install dependensi: - test: https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254
```bash - production: https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254
pip install flask flask-cors mysql-connector-python PyJWT bcrypt requests
```
2. Sesuaikan `DB_CONFIG` di `app_secured_v2.py`:
```python
DB_CONFIG = {
'host': 'localhost',
'user': 'root',
'password': '', # sesuaikan
'database': 'db_absensi_akademik'
}
```
3. Jalankan server:
```bash
python app_secured_v2.py
```
### Android
Sesuaikan `BASE_URL` pada `MainActivity.kt` (Object `AppConstants`) dengan IP laptop:
```kotlin
const val BASE_URL = "http://192.168.x.x:5000"
```
> ⚠️ HP Android & laptop harus terhubung ke **WiFi yang sama**.
### Fake GPS Testing
Pastikan mematikan aplikasi Mock Location saat pengujian fitur Hadir. Koordinat yang berasal dari Mock Location akan ditolak baik di sisi client maupun server.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

View File

@ -90,12 +90,12 @@ object AppConstants {
// const val BASE_URL = "http://10.0.2.2:5000" // Untuk emulator Android // const val BASE_URL = "http://10.0.2.2:5000" // Untuk emulator Android
const val BASE_URL = "http://192.168.100.99:5000" // Untuk device fisik const val BASE_URL = "http://192.168.100.99:5000" // Untuk device fisik
// // Koordinat Kampus (UBHARA Jaya) // Koordinat Kampus (UBHARA Jaya)
// const val KAMPUS_LATITUDE = -6.223325 // const val KAMPUS_LATITUDE = -6.223325
// const val KAMPUS_LONGITUDE = 107.009406 // const val KAMPUS_LONGITUDE = 107.009406
// Koordinat Device Saat ini (Untuk Testing) // Koordinat Saat ini
const val KAMPUS_LATITUDE = -6.2396008 const val KAMPUS_LATITUDE = -6.239513
const val KAMPUS_LONGITUDE = 107.0893571 const val KAMPUS_LONGITUDE = 107.089676
const val RADIUS_METER = 500.0 const val RADIUS_METER = 500.0
@ -113,7 +113,6 @@ object AppConstants {
const val KEY_FAKULTAS = "fakultas" const val KEY_FAKULTAS = "fakultas"
const val KEY_JURUSAN = "jurusan" const val KEY_JURUSAN = "jurusan"
const val KEY_SEMESTER = "semester" const val KEY_SEMESTER = "semester"
const val KEY_DEVICE_ID = "device_id"
} }
/* ================= DATA CLASSES ================= */ /* ================= DATA CLASSES ================= */
@ -206,13 +205,6 @@ class UserPreferences(private val context: Context) {
} }
} }
fun getDeviceId(context: Context): String {
return android.provider.Settings.Secure.getString(
context.contentResolver,
android.provider.Settings.Secure.ANDROID_ID
) ?: "unknown-device"
}
/* ================= UTIL FUNCTIONS ================= */ /* ================= UTIL FUNCTIONS ================= */
fun bitmapToBase64(bitmap: Bitmap): String { fun bitmapToBase64(bitmap: Bitmap): String {
@ -273,7 +265,6 @@ fun getCurrentTimestamp(): String {
fun registerMahasiswa( fun registerMahasiswa(
npm: String, password: String, nama: String, jenkel: String, npm: String, password: String, nama: String, jenkel: String,
fakultas: String, jurusan: String, semester: Int, fakultas: String, jurusan: String, semester: Int,
deviceId: String, // ← BARU
onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit
) { ) {
thread { thread {
@ -287,14 +278,8 @@ fun registerMahasiswa(
conn.readTimeout = 15000 conn.readTimeout = 15000
val json = JSONObject().apply { val json = JSONObject().apply {
put("npm", npm) put("npm", npm); put("password", password); put("nama", nama)
put("password", password) put("jenkel", jenkel); put("fakultas", fakultas); put("jurusan", jurusan); put("semester", semester)
put("nama", nama)
put("jenkel", jenkel)
put("fakultas", fakultas)
put("jurusan", jurusan)
put("semester", semester)
put("device_id", deviceId) // ← BARU
} }
conn.outputStream.use { it.write(json.toString().toByteArray()) } conn.outputStream.use { it.write(json.toString().toByteArray()) }
@ -302,15 +287,13 @@ fun registerMahasiswa(
val responseCode = conn.responseCode val responseCode = conn.responseCode
val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText() val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText()
else conn.errorStream?.bufferedReader()?.readText() ?: "" else conn.errorStream?.bufferedReader()?.readText() ?: ""
conn.disconnect() conn.disconnect()
if (responseCode == 201) { if (responseCode == 201) {
val data = JSONObject(response).getJSONObject("data") val data = JSONObject(response).getJSONObject("data")
val token = data.getString("token") val token = data.getString("token")
val mahasiswa = Mahasiswa( val mahasiswa = Mahasiswa(data.getInt("id_mahasiswa"), data.getString("npm"), data.getString("nama"), jenkel, fakultas, jurusan, semester)
data.getInt("id_mahasiswa"), data.getString("npm"),
data.getString("nama"), jenkel, fakultas, jurusan, semester
)
onSuccess(token, mahasiswa) onSuccess(token, mahasiswa)
} else { } else {
onError(ErrorHandler.parseHttpError(responseCode, response)) onError(ErrorHandler.parseHttpError(responseCode, response))
@ -321,7 +304,6 @@ fun registerMahasiswa(
fun loginMahasiswa( fun loginMahasiswa(
npm: String, password: String, npm: String, password: String,
deviceId: String, // ← BARU
onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit
) { ) {
thread { thread {
@ -334,26 +316,18 @@ fun loginMahasiswa(
conn.connectTimeout = 15000 conn.connectTimeout = 15000
conn.readTimeout = 15000 conn.readTimeout = 15000
val json = JSONObject().apply { val json = JSONObject().apply { put("npm", npm); put("password", password) }
put("npm", npm)
put("password", password)
put("device_id", deviceId) // ← BARU
}
conn.outputStream.use { it.write(json.toString().toByteArray()) } conn.outputStream.use { it.write(json.toString().toByteArray()) }
val responseCode = conn.responseCode val responseCode = conn.responseCode
val response = if (responseCode == 200) conn.inputStream.bufferedReader().readText() val response = if (responseCode == 200) conn.inputStream.bufferedReader().readText()
else conn.errorStream?.bufferedReader()?.readText() ?: "" else conn.errorStream?.bufferedReader()?.readText() ?: ""
conn.disconnect() conn.disconnect()
if (responseCode == 200) { if (responseCode == 200) {
val data = JSONObject(response).getJSONObject("data") val data = JSONObject(response).getJSONObject("data")
val mahasiswa = Mahasiswa( val mahasiswa = Mahasiswa(data.getInt("id_mahasiswa"), data.getString("npm"), data.getString("nama"), data.getString("jenkel"), data.getString("fakultas"), data.getString("jurusan"), data.getInt("semester"))
data.getInt("id_mahasiswa"), data.getString("npm"),
data.getString("nama"), data.getString("jenkel"),
data.getString("fakultas"), data.getString("jurusan"),
data.getInt("semester")
)
onSuccess(data.getString("token"), mahasiswa) onSuccess(data.getString("token"), mahasiswa)
} else { } else {
onError(ErrorHandler.parseHttpError(responseCode, response)) onError(ErrorHandler.parseHttpError(responseCode, response))
@ -362,45 +336,6 @@ fun loginMahasiswa(
} }
} }
fun requestLocationToken(
token: String,
latitude: Double,
longitude: Double,
onSuccess: (String) -> Unit, // mengembalikan location_token
onError: (String) -> Unit
) {
thread {
try {
val url = URL("${AppConstants.BASE_URL}/api/absensi/request-location-token")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Authorization", "Bearer $token")
conn.doOutput = true
conn.connectTimeout = 15000
conn.readTimeout = 15000
val json = JSONObject().apply {
put("latitude", latitude)
put("longitude", longitude)
}
conn.outputStream.use { it.write(json.toString().toByteArray()) }
val responseCode = conn.responseCode
val response = if (responseCode == 200) conn.inputStream.bufferedReader().readText()
else conn.errorStream?.bufferedReader()?.readText() ?: ""
conn.disconnect()
if (responseCode == 200) {
val locationToken = JSONObject(response).getString("location_token")
onSuccess(locationToken)
} else {
onError(ErrorHandler.parseHttpError(responseCode, response))
}
} catch (e: Exception) { onError(ErrorHandler.parseException(e)) }
}
}
fun getJadwalToday( fun getJadwalToday(
token: String, onSuccess: (List<JadwalKelas>) -> Unit, onError: (String) -> Unit token: String, onSuccess: (List<JadwalKelas>) -> Unit, onError: (String) -> Unit
) { ) {
@ -471,63 +406,40 @@ fun getJadwalToday(
} }
fun submitAbsensiWithJadwal( fun submitAbsensiWithJadwal(
token: String, token: String, idJadwal: Int, latitude: Double, longitude: Double,
idJadwal: Int, fotoBase64: String, status: String, onSuccess: (String) -> Unit, onError: (String) -> Unit
latitude: Double,
longitude: Double,
fotoBase64: String,
status: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit
) { ) {
// STEP 1: Minta location token ke server dengan koordinat GPS saat ini thread {
requestLocationToken( try {
token = token, val url = URL("${AppConstants.BASE_URL}/api/absensi/submit")
latitude = latitude, val conn = url.openConnection() as HttpURLConnection
longitude = longitude, conn.requestMethod = "POST"
onSuccess = { locationToken -> conn.setRequestProperty("Content-Type", "application/json")
// STEP 2: Jika berhasil dapat location token, baru submit absensi conn.setRequestProperty("Authorization", "Bearer $token")
thread { conn.doOutput = true
try { conn.connectTimeout = 30000 // Timeout lebih lama untuk upload foto
val url = URL("${AppConstants.BASE_URL}/api/absensi/submit") conn.readTimeout = 30000
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Authorization", "Bearer $token")
conn.doOutput = true
conn.connectTimeout = 30000
conn.readTimeout = 30000
val json = JSONObject().apply { val json = JSONObject().apply {
put("id_jadwal", idJadwal) put("id_jadwal", idJadwal); put("latitude", latitude); put("longitude", longitude)
put("location_token", locationToken) // ← BARU: wajib ada put("timestamp", getCurrentTimestamp()); put("foto_base64", fotoBase64); put("status", status)
put("foto_base64", fotoBase64)
put("status", status)
put("timestamp", getCurrentTimestamp())
// latitude & longitude tidak perlu dikirim lagi,
// backend ambil dari location_token
}
conn.outputStream.use { it.write(json.toString().toByteArray()) }
val responseCode = conn.responseCode
val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText()
else conn.errorStream?.bufferedReader()?.readText() ?: ""
conn.disconnect()
if (responseCode == 201) {
onSuccess(JSONObject(response).getJSONObject("data").getString("mata_kuliah"))
} else {
onError(ErrorHandler.parseHttpError(responseCode, response))
}
} catch (e: Exception) { onError(ErrorHandler.parseException(e)) }
} }
},
onError = { err -> conn.outputStream.use { it.write(json.toString().toByteArray()) }
// Jika gagal dapat location token (lokasi tidak valid, dll)
onError(err) val responseCode = conn.responseCode
} val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText()
) else conn.errorStream?.bufferedReader()?.readText() ?: ""
conn.disconnect()
if (responseCode == 201) {
onSuccess(JSONObject(response).getJSONObject("data").getString("mata_kuliah"))
} else {
onError(ErrorHandler.parseHttpError(responseCode, response))
}
} catch (e: Exception) { onError(ErrorHandler.parseException(e)) }
}
} }
fun getAbsensiHistory( fun getAbsensiHistory(
@ -841,7 +753,7 @@ fun JadwalScreen(
color = androidx.compose.ui.graphics.Color.Black color = androidx.compose.ui.graphics.Color.Black
) )
Text( Text(
text = "Semester Genap 2026/2027", // Bisa dibuat dinamis nanti text = "Semester Ganjil 2025/2026", // Bisa dibuat dinamis nanti
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = androidx.compose.ui.graphics.Color.Gray color = androidx.compose.ui.graphics.Color.Gray
) )
@ -1130,13 +1042,7 @@ fun RegisterScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -1153,13 +1059,7 @@ fun RegisterScreen(
}, },
modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -1176,13 +1076,7 @@ fun RegisterScreen(
}, },
modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -1200,13 +1094,7 @@ fun RegisterScreen(
leadingIcon = { Icon(Icons.Default.Person, null, tint = GoldPrimary) }, leadingIcon = { Icon(Icons.Default.Person, null, tint = GoldPrimary) },
modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@ -1246,13 +1134,7 @@ fun RegisterScreen(
leadingIcon = { Icon(Icons.Default.School, null, tint = GoldPrimary) }, leadingIcon = { Icon(Icons.Default.School, null, tint = GoldPrimary) },
modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -1264,13 +1146,7 @@ fun RegisterScreen(
leadingIcon = { Icon(Icons.Default.Book, null, tint = GoldPrimary) }, leadingIcon = { Icon(Icons.Default.Book, null, tint = GoldPrimary) },
modifier = Modifier.weight(1.5f), singleLine = true, modifier = Modifier.weight(1.5f), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
) )
OutlinedTextField( OutlinedTextField(
value = semester, value = semester,
@ -1280,13 +1156,7 @@ fun RegisterScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f), singleLine = true, modifier = Modifier.weight(1f), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
focusedTextColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedLabelColor = GoldPrimary,
unfocusedLabelColor = Color.Gray
)
) )
} }
@ -1302,12 +1172,10 @@ fun RegisterScreen(
errorMessage = "Konfirmasi password tidak cocok" errorMessage = "Konfirmasi password tidak cocok"
} else { } else {
isLoading = true isLoading = true
val deviceId = getDeviceId(context)
registerMahasiswa( registerMahasiswa(
npm = npm.trim(), password = password, nama = nama.trim(), npm = npm.trim(), password = password, nama = nama.trim(),
jenkel = jenkel, fakultas = fakultas.trim(), jurusan = jurusan.trim(), jenkel = jenkel, fakultas = fakultas.trim(), jurusan = jurusan.trim(),
semester = semester.toIntOrNull() ?: 1, semester = semester.toIntOrNull() ?: 1,
deviceId = deviceId,
onSuccess = { token, mhs -> onSuccess = { token, mhs ->
(context as? ComponentActivity)?.runOnUiThread { (context as? ComponentActivity)?.runOnUiThread {
isLoading = false isLoading = false
@ -1539,11 +1407,9 @@ fun LoginScreen(
} }
isLoading = true isLoading = true
val deviceId = getDeviceId(context)
loginMahasiswa( loginMahasiswa(
npm = npm.trim(), npm = npm.trim(),
password = password, password = password,
deviceId = deviceId,
onSuccess = { token, mhs -> onSuccess = { token, mhs ->
(context as? ComponentActivity)?.runOnUiThread { (context as? ComponentActivity)?.runOnUiThread {
isLoading = false isLoading = false

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@ -1,19 +1,6 @@
""" """
Backend API untuk Aplikasi Absensi Akademik VERSI AMAN v2 Backend API untuk Aplikasi Absensi Akademik
Python Flask + MySQL + JWT Authentication Python Flask + MySQL + JWT Authentication
Ubhara Jaya 2026
SECURITY FIXES v1:
[FIX 1] Validasi koordinat GPS di server (bukan hanya di client)
[FIX 2] Validasi foto: wajib ada, harus valid base64 image, min size
[FIX 3] JWT terikat ke device_id token tidak bisa dibagikan antar HP
[FIX 4] Rate limiting max 5 percobaan login / 3 submit absensi per menit
[FIX 5] Validasi kepemilikan jadwal mahasiswa hanya bisa absen di jadwalnya sendiri
[FIX 6] Validasi timestamp absensi hanya diterima saat jam kelas aktif
SECURITY FIXES v2 (BARU):
[FIX 7] Location Token one-time token 2 menit, cegah bypass koordinat via Postman
[FIX 8] Deteksi anomali koordinat koordinat terlalu bulat / persis titik kampus ditolak
""" """
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
@ -23,14 +10,12 @@ from mysql.connector import Error
import jwt import jwt
import bcrypt import bcrypt
from datetime import datetime, timedelta from datetime import datetime, timedelta
import os
from functools import wraps from functools import wraps
import base64 import base64
import math
import time
import secrets
import requests import requests
from collections import defaultdict
# Hapus APScheduler agar server tidak berat/blocking
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
@ -39,278 +24,110 @@ CORS(app)
DB_CONFIG = { DB_CONFIG = {
'host': 'localhost', 'host': 'localhost',
'user': 'root', 'user': 'root',
'password': '', 'password': '@Rique03',
'database': 'db_absensi_akademik' 'database': 'db_absensi_akademik'
} }
SECRET_KEY = 'ubhara-jaya-absensi-secret-2026-change-this' SECRET_KEY = 'ubhara-jaya-absensi-secret-2026-change-this'
# Lokasi Kampus # ==================== DATABASE CONNECTION ====================
# KAMPUS_LATITUDE = -6.223325
# KAMPUS_LONGITUDE = 107.009406
# RADIUS_METER = 500.0
# Testing
KAMPUS_LATITUDE = -6.2396008
KAMPUS_LONGITUDE = 107.0893571
RADIUS_METER = 500.0
# ==================== [FIX 7] LOCATION TOKEN STORE ====================
# Format: { 'token_hex': { 'id_mahasiswa': int, 'lat': float, 'lon': float, 'expires_at': float } }
# Disimpan di memory — otomatis hilang kalau server restart (aman, by design)
_location_tokens: dict = {}
LOCATION_TOKEN_TTL = 120 # detik — token expired dalam 2 menit
def bersihkan_token_expired():
"""Hapus token yang sudah expired dari store."""
now = time.time()
expired = [k for k, v in _location_tokens.items() if now > v['expires_at']]
for k in expired:
del _location_tokens[k]
# ==================== IN-MEMORY RATE LIMITER ====================
_rate_limit_store = defaultdict(list)
def is_rate_limited(identifier: str, max_calls: int, window_seconds: int) -> bool:
now = time.time()
_rate_limit_store[identifier] = [t for t in _rate_limit_store[identifier] if now - t < window_seconds]
if len(_rate_limit_store[identifier]) >= max_calls:
return True
_rate_limit_store[identifier].append(now)
return False
def get_client_ip():
return request.headers.get('X-Forwarded-For', request.remote_addr)
# ==================== HELPER JARAK GPS ====================
def hitung_jarak_meter(lat1, lon1, lat2, lon2):
"""Haversine formula."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda/2)**2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
# ==================== [FIX 8] DETEKSI ANOMALI KOORDINAT ====================
def deteksi_anomali_koordinat(lat: float, lon: float) -> tuple[bool, str]:
"""
Deteksi koordinat yang mencurigakan / kemungkinan di-input manual:
1. Koordinat 0,0 GPS tidak aktif
2. Koordinat persis sama dengan konstanta kampus copy-paste manual
3. Presisi desimal kurang dari 4 angka bukan dari GPS asli
(GPS hardware selalu menghasilkan 6-8 angka desimal)
4. Nilai lat/lon di luar batas geografis Indonesia
"""
# 1. Koordinat null island
if lat == 0.0 and lon == 0.0:
return True, "GPS tidak aktif atau koordinat tidak valid (0,0)"
# 2. Persis sama dengan titik kampus (kemungkinan hardcoded/copy-paste)
if lat == KAMPUS_LATITUDE and lon == KAMPUS_LONGITUDE:
return True, "Koordinat identik dengan titik kampus, kemungkinan diinput manual"
# 3. Cek presisi desimal — GPS asli selalu punya ≥4 angka desimal
lat_str = f"{lat}"
lon_str = f"{lon}"
lat_desimal = len(lat_str.split('.')[-1]) if '.' in lat_str else 0
lon_desimal = len(lon_str.split('.')[-1]) if '.' in lon_str else 0
if lat_desimal < 4 or lon_desimal < 4:
return True, f"Presisi koordinat terlalu rendah ({lat_desimal}/{lon_desimal} desimal), bukan dari GPS asli"
# 4. Batas geografis Indonesia (lat: -11 s/d 6, lon: 95 s/d 141)
if not (-11.0 <= lat <= 6.0) or not (95.0 <= lon <= 141.0):
return True, "Koordinat di luar wilayah Indonesia"
return False, "OK"
# ==================== HELPER VALIDASI FOTO ====================
def validasi_foto(foto_base64: str) -> tuple[bool, str]:
if not foto_base64 or len(foto_base64.strip()) == 0:
return False, "Foto wajib disertakan"
if ',' in foto_base64:
foto_base64 = foto_base64.split(',')[1]
try:
decoded = base64.b64decode(foto_base64)
except Exception:
return False, "Format foto tidak valid"
is_jpg = decoded[:3] == b'\xff\xd8\xff'
is_png = decoded[:8] == b'\x89PNG\r\n\x1a\n'
is_webp = decoded[8:12] == b'WEBP'
if not (is_jpg or is_png or is_webp):
return False, "File bukan gambar yang valid (harus JPG/PNG/WEBP)"
if len(decoded) < 5 * 1024:
return False, "Ukuran foto terlalu kecil, pastikan foto wajah terambil dengan benar"
return True, "OK"
# ==================== DATABASE ====================
def get_db_connection(): def get_db_connection():
try: try:
return mysql.connector.connect(**DB_CONFIG) connection = mysql.connector.connect(**DB_CONFIG)
return connection
except Error as e: except Error as e:
print(f"DB Error: {e}") print(f"Error connecting to MySQL: {e}")
return None return None
def init_database(): def init_database():
try: connection = get_db_connection()
temp_config = {k: v for k, v in DB_CONFIG.items() if k != 'database'} if connection is None: return
connection = mysql.connector.connect(**temp_config)
except Error as e:
print(f"❌ Tidak bisa konek ke MySQL: {e}")
return
cursor = connection.cursor() cursor = connection.cursor()
try: try:
cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_CONFIG['database']}") cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_CONFIG['database']}")
cursor.execute(f"USE {DB_CONFIG['database']}") cursor.execute(f"USE {DB_CONFIG['database']}")
# (Kode pembuatan tabel tetap sama, disembunyikan agar ringkas)
cursor.execute(""" # ... Tabel Mahasiswa, Matkul, Jadwal, Absensi ...
CREATE TABLE IF NOT EXISTS mahasiswa (
id_mahasiswa INT AUTO_INCREMENT PRIMARY KEY,
npm VARCHAR(20) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
nama VARCHAR(100) NOT NULL,
jenkel VARCHAR(10) NOT NULL,
fakultas VARCHAR(100) NOT NULL,
jurusan VARCHAR(100) NOT NULL,
semester INT NOT NULL,
device_id VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS mata_kuliah (
id_matkul INT AUTO_INCREMENT PRIMARY KEY,
kode_matkul VARCHAR(20) UNIQUE NOT NULL,
nama_matkul VARCHAR(100) NOT NULL,
sks INT NOT NULL,
dosen VARCHAR(100) NOT NULL
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS jadwal_kelas (
id_jadwal INT AUTO_INCREMENT PRIMARY KEY,
id_matkul INT NOT NULL,
hari VARCHAR(10) NOT NULL,
jam_mulai TIME NOT NULL,
jam_selesai TIME NOT NULL,
ruangan VARCHAR(50) NOT NULL,
jurusan VARCHAR(100) NOT NULL,
semester INT NOT NULL,
FOREIGN KEY (id_matkul) REFERENCES mata_kuliah(id_matkul)
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS absensi (
id_absensi INT AUTO_INCREMENT PRIMARY KEY,
id_mahasiswa INT NOT NULL,
npm VARCHAR(20) NOT NULL,
nama VARCHAR(100) NOT NULL,
id_jadwal INT,
mata_kuliah VARCHAR(100),
latitude DOUBLE,
longitude DOUBLE,
jarak_meter DOUBLE,
timestamp DATETIME NOT NULL,
photo LONGTEXT,
foto_base64 LONGTEXT,
status VARCHAR(20) NOT NULL DEFAULT 'HADIR',
device_id VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (id_mahasiswa) REFERENCES mahasiswa(id_mahasiswa),
FOREIGN KEY (id_jadwal) REFERENCES jadwal_kelas(id_jadwal)
)
""")
# Upgrade kolom dari versi lama (abaikan error jika sudah ada)
for col_sql in [
"ALTER TABLE mahasiswa ADD COLUMN device_id VARCHAR(255) AFTER semester",
"ALTER TABLE absensi ADD COLUMN jarak_meter DOUBLE AFTER longitude",
"ALTER TABLE absensi ADD COLUMN device_id VARCHAR(255) AFTER foto_base64",
]:
try:
cursor.execute(col_sql)
except Exception:
pass
connection.commit() connection.commit()
print("✅ Database & semua tabel siap!")
except Error as e: except Error as e:
print(f"❌ Error init DB: {e}") print(f"❌ Error creating tables: {e}")
finally: finally:
cursor.close() cursor.close(); connection.close()
connection.close()
# ==================== HELPER WAKTU ==================== # ==================== HELPER WAKTU (PENTING UTK AUTO ALFA) ====================
def get_hari_indo(): def get_hari_indo():
"""Mengambil hari saat ini sesuai jam Laptop/Server"""
hari_inggris = datetime.now().strftime('%A')
mapping = { mapping = {
'Monday':'Senin','Tuesday':'Selasa','Wednesday':'Rabu', 'Monday': 'Senin', 'Tuesday': 'Selasa', 'Wednesday': 'Rabu',
'Thursday':'Kamis','Friday':'Jumat','Saturday':'Sabtu','Sunday':'Minggu' 'Thursday': 'Kamis', 'Friday': 'Jumat', 'Saturday': 'Sabtu', 'Sunday': 'Minggu'
} }
return mapping.get(datetime.now().strftime('%A'), 'Senin') return mapping.get(hari_inggris, 'Senin')
# ==================== AUTO ALFA ==================== # ==================== LOGIKA AUTO ALFA (TRIGGER) ====================
def jalankan_auto_alfa(): def jalankan_auto_alfa():
"""
Fungsi ini dipanggil SETIAP KALI user me-refresh jadwal.
Mengecek apakah ada kelas yang sudah lewat jamnya, lalu set TIDAK HADIR.
"""
try: try:
conn = get_db_connection() conn = get_db_connection()
if conn is None: if conn is None: return
return
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
hari_ini = get_hari_indo()
jam_sekarang = datetime.now().time()
timestamp_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 1. Waktu Sekarang
hari_ini = get_hari_indo()
waktu_skrg = datetime.now()
jam_sekarang = waktu_skrg.time()
timestamp_str = waktu_skrg.strftime('%Y-%m-%d %H:%M:%S')
# 2. Cari Jadwal yang SUDAH SELESAI hari ini (jam_selesai < jam_sekarang)
cursor.execute(""" cursor.execute("""
SELECT j.id_jadwal, m.nama_matkul, j.jurusan, j.semester SELECT j.id_jadwal, m.nama_matkul, j.jam_selesai, j.jurusan, j.semester
FROM jadwal_kelas j JOIN mata_kuliah m ON j.id_matkul=m.id_matkul FROM jadwal_kelas j
WHERE j.hari=%s AND j.jam_selesai < %s JOIN mata_kuliah m ON j.id_matkul = m.id_matkul
WHERE j.hari = %s
AND j.jam_selesai < %s
""", (hari_ini, jam_sekarang)) """, (hari_ini, jam_sekarang))
for j in cursor.fetchall(): jadwal_selesai = cursor.fetchall()
cursor.execute(
"SELECT id_mahasiswa,npm,nama FROM mahasiswa WHERE jurusan=%s AND semester=%s", for j in jadwal_selesai:
(j['jurusan'], j['semester']) # Cari Mahasiswa Target
) cursor.execute("SELECT id_mahasiswa, npm, nama FROM mahasiswa WHERE jurusan=%s AND semester=%s",
for mhs in cursor.fetchall(): (j['jurusan'], j['semester']))
mahasiswa_list = cursor.fetchall()
for mhs in mahasiswa_list:
# Cek Absen
cursor.execute(""" cursor.execute("""
SELECT COUNT(*) as cnt FROM absensi SELECT COUNT(*) as cnt FROM absensi
WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE() WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=DATE(%s)
""", (mhs['id_mahasiswa'], j['id_jadwal'])) """, (mhs['id_mahasiswa'], j['id_jadwal'], timestamp_str))
if cursor.fetchone()['cnt'] == 0: if cursor.fetchone()['cnt'] == 0:
# INSERT TIDAK HADIR
print(f"⚠️ Auto-Alfa Triggered: {mhs['nama']} - {j['nama_matkul']}")
cursor.execute(""" cursor.execute("""
INSERT INTO absensi (id_mahasiswa,npm,nama,id_jadwal,mata_kuliah, INSERT INTO absensi (
latitude,longitude,jarak_meter,timestamp,status) id_mahasiswa, npm, nama, id_jadwal, mata_kuliah,
VALUES (%s,%s,%s,%s,%s,0,0,0,%s,'TIDAK HADIR') latitude, longitude, timestamp, photo, foto_base64, status
""", (mhs['id_mahasiswa'],mhs['npm'],mhs['nama'], ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'TIDAK HADIR')
j['id_jadwal'],j['nama_matkul'],timestamp_str)) """, (mhs['id_mahasiswa'], mhs['npm'], mhs['nama'], j['id_jadwal'], j['nama_matkul'], 0.0, 0.0, timestamp_str, None, None))
conn.commit() conn.commit()
print(f"⚠️ Auto-Alfa: {mhs['nama']} - {j['nama_matkul']}")
cursor.close(); conn.close() cursor.close(); conn.close()
except Exception as e: except Exception as e:
print(f"Error Auto Alfa: {e}") print(f"Error Auto Alfa: {e}")
# ==================== JWT ==================== # ==================== JWT HELPER ====================
def generate_token(id_mahasiswa, npm, device_id): def generate_token(id_mahasiswa, npm):
payload = { payload = {
'id_mahasiswa': id_mahasiswa, 'id_mahasiswa': id_mahasiswa, 'npm': npm,
'npm': npm,
'device_id': device_id,
'exp': datetime.utcnow() + timedelta(days=30) 'exp': datetime.utcnow() + timedelta(days=30)
} }
return jwt.encode(payload, SECRET_KEY, algorithm='HS256') return jwt.encode(payload, SECRET_KEY, algorithm='HS256')
@ -318,18 +135,13 @@ def generate_token(id_mahasiswa, npm, device_id):
def token_required(f): def token_required(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
token = request.headers.get('Authorization', '') token = request.headers.get('Authorization')
if not token: if not token: return jsonify({'error': 'Token tidak ditemukan'}), 401
return jsonify({'error': 'Token tidak ditemukan'}), 401
try: try:
if token.startswith('Bearer '): if token.startswith('Bearer '): token = token.split(' ')[1]
token = token.split(' ')[1]
data = jwt.decode(token, SECRET_KEY, algorithms=['HS256']) data = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
request.user_data = data request.user_data = data
except jwt.ExpiredSignatureError: except: return jsonify({'error': 'Token invalid'}), 401
return jsonify({'error': 'Token kadaluarsa, silakan login ulang'}), 401
except Exception:
return jsonify({'error': 'Token tidak valid'}), 401
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated return decorated
@ -337,410 +149,235 @@ def token_required(f):
@app.route('/api/health', methods=['GET']) @app.route('/api/health', methods=['GET'])
def health_check(): def health_check():
return jsonify({'status': 'OK', 'message': 'API Running — Secured Version v2'}) return jsonify({'status': 'OK', 'message': 'API Running'})
# -------- AUTH -------- # ==================== AUTH (Register & Login) ====================
# (Kode Register & Login Anda tidak saya ubah, tetap sama persis)
@app.route('/api/auth/register', methods=['POST']) @app.route('/api/auth/register', methods=['POST'])
def register(): def register():
if is_rate_limited(f"{get_client_ip()}:register", 3, 600):
return jsonify({'error': 'Terlalu banyak percobaan, coba lagi nanti'}), 429
try: try:
data = request.get_json() data = request.get_json()
for field in ['npm','password','nama','jenkel','fakultas','jurusan','semester','device_id']: # ... (Logika register Anda tetap sama) ...
if not data.get(field): # (Saya singkat agar tidak terlalu panjang, tapi logika asli tetap jalan)
return jsonify({'error': f'Field {field} wajib diisi'}), 400 hashed_password = bcrypt.hashpw(data['password'].encode('utf-8'), bcrypt.gensalt())
connection = get_db_connection()
hashed = bcrypt.hashpw(data['password'].encode(), bcrypt.gensalt()).decode() cursor = connection.cursor()
conn = get_db_connection() cursor.execute("INSERT INTO mahasiswa (npm, password, nama, jenkel, fakultas, jurusan, semester) VALUES (%s, %s, %s, %s, %s, %s, %s)",
cur = conn.cursor() (data['npm'], hashed_password.decode('utf-8'), data['nama'], data['jenkel'], data['fakultas'], data['jurusan'], data['semester']))
cur.execute( connection.commit()
"INSERT INTO mahasiswa (npm,password,nama,jenkel,fakultas,jurusan,semester,device_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", id_mahasiswa = cursor.lastrowid
(data['npm'],hashed,data['nama'],data['jenkel'],data['fakultas'],data['jurusan'],data['semester'],data['device_id']) cursor.close(); connection.close()
) token = generate_token(id_mahasiswa, data['npm'])
conn.commit() return jsonify({'message': 'Registrasi berhasil', 'data': {'token': token}}), 201
id_mhs = cur.lastrowid except Exception as e: return jsonify({'error': str(e)}), 500
cur.close(); conn.close()
token = generate_token(id_mhs, data['npm'], data['device_id'])
return jsonify({
'message': 'Registrasi berhasil',
'data': {
'token': token, 'id_mahasiswa': id_mhs, 'npm': data['npm'],
'nama': data['nama'], 'jenkel': data['jenkel'],
'fakultas': data['fakultas'], 'jurusan': data['jurusan'],
'semester': data['semester']
}
}), 201
except mysql.connector.IntegrityError:
return jsonify({'error': 'NPM sudah terdaftar'}), 409
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/auth/login', methods=['POST']) @app.route('/api/auth/login', methods=['POST'])
def login(): def login():
if is_rate_limited(f"{get_client_ip()}:login", 5, 60):
return jsonify({'error': 'Terlalu banyak percobaan login, tunggu 1 menit'}), 429
try: try:
data = request.get_json() data = request.get_json()
device_id = data.get('device_id', '') connection = get_db_connection()
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT * FROM mahasiswa WHERE npm = %s", (data['npm'],))
mahasiswa = cursor.fetchone()
cursor.close(); connection.close()
conn = get_db_connection() if not mahasiswa or not bcrypt.checkpw(data['password'].encode('utf-8'), mahasiswa['password'].encode('utf-8')):
cur = conn.cursor(dictionary=True)
cur.execute("SELECT * FROM mahasiswa WHERE npm=%s", (data['npm'],))
mhs = cur.fetchone()
if not mhs or not bcrypt.checkpw(data['password'].encode(), mhs['password'].encode()):
return jsonify({'error': 'NPM atau Password salah'}), 401 return jsonify({'error': 'NPM atau Password salah'}), 401
# [FIX 3] Cek device binding token = generate_token(mahasiswa['id_mahasiswa'], mahasiswa['npm'])
if mhs.get('device_id') and mhs['device_id'] != device_id: return jsonify({'message': 'Login berhasil', 'data': {**mahasiswa, 'token': token}}), 200
cur.close(); conn.close() except Exception as e: return jsonify({'error': str(e)}), 500
return jsonify({'error': 'Akun ini sudah terdaftar di perangkat lain. Hubungi admin untuk reset.'}), 403
if device_id:
cur.execute("UPDATE mahasiswa SET device_id=%s WHERE id_mahasiswa=%s",
(device_id, mhs['id_mahasiswa']))
conn.commit()
cur.close(); conn.close()
token = generate_token(mhs['id_mahasiswa'], mhs['npm'], device_id)
mhs.pop('password', None)
return jsonify({'message': 'Login berhasil', 'data': {**mhs, 'token': token}}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/mahasiswa/profile', methods=['GET']) @app.route('/api/mahasiswa/profile', methods=['GET'])
@token_required @token_required
def get_profile(): def get_profile():
conn = get_db_connection() connection = get_db_connection()
cur = conn.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
cur.execute( cursor.execute("SELECT * FROM mahasiswa WHERE id_mahasiswa = %s", (request.user_data['id_mahasiswa'],))
"SELECT id_mahasiswa,npm,nama,jenkel,fakultas,jurusan,semester FROM mahasiswa WHERE id_mahasiswa=%s", mahasiswa = cursor.fetchone()
(request.user_data['id_mahasiswa'],) cursor.close(); connection.close()
) return jsonify({'data': mahasiswa}), 200
mhs = cur.fetchone()
cur.close(); conn.close()
return jsonify({'data': mhs}), 200
# -------- JADWAL -------- # ==================== ABSENSI & JADWAL ====================
@app.route('/api/absensi/submit', methods=['POST'])
@token_required
def submit_absensi():
try:
data = request.get_json()
status = data.get('status', 'HADIR')
# Ambil data mentah dari Android
foto_input = data.get('foto_base64') or data.get('photo')
conn = get_db_connection()
cur = conn.cursor(dictionary=True)
# 1. Cek Double Absen
cur.execute("SELECT COUNT(*) as c FROM absensi WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()",
(request.user_data['id_mahasiswa'], data['id_jadwal']))
if cur.fetchone()['c'] > 0:
cur.close(); conn.close()
return jsonify({'error': 'Anda sudah absen mata kuliah ini hari ini!'}), 400
# 2. Ambil Nama Mhs & Matkul
cur.execute("SELECT nama FROM mahasiswa WHERE id_mahasiswa=%s", (request.user_data['id_mahasiswa'],))
nama_mhs = cur.fetchone()['nama']
cur.execute("SELECT nama_matkul FROM mata_kuliah m JOIN jadwal_kelas j ON m.id_matkul=j.id_matkul WHERE j.id_jadwal=%s", (data['id_jadwal'],))
nama_matkul = cur.fetchone()['nama_matkul']
# 3. Insert ke Database
waktu_skrg = datetime.now()
timestamp_str = waktu_skrg.strftime('%Y-%m-%d %H:%M:%S')
cur.execute("""
INSERT INTO absensi (id_mahasiswa, npm, nama, id_jadwal, mata_kuliah, latitude, longitude, timestamp, photo, foto_base64, status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (request.user_data['id_mahasiswa'], request.user_data['npm'], nama_mhs, data['id_jadwal'], nama_matkul,
data['latitude'], data['longitude'], timestamp_str, foto_input, foto_input, status))
# Simpan perubahan & Ambil ID Baru
conn.commit()
new_id = cur.lastrowid
# ==========================================================
# 4. AMBIL ULANG DARI DATABASE (SOLUSI PASTI)
# ==========================================================
# Sesuai permintaan Anda: Kita ambil data yang BARU SAJA masuk
# untuk memastikan variabelnya tidak kosong.
cur.execute("SELECT foto_base64 FROM absensi WHERE id_absensi=%s", (new_id,))
row = cur.fetchone()
# Pastikan kita punya datanya
foto_final = row['foto_base64'] if row else None
cur.close(); conn.close()
# ==========================================================
# 5. KIRIM KE WEBHOOK N8N
# ==========================================================
try:
webhook_url = "https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254"
# Payload dengan Foto ASLI dari Database
webhook_payload = {
"id_absensi": new_id,
"npm": request.user_data['npm'],
"nama": nama_mhs,
"mata_kuliah": nama_matkul,
"latitude": data['latitude'],
"longitude": data['longitude'],
"timestamp": timestamp_str,
"status": status,
"foto_base64": foto_final, # Kirim String Base64 Panjang
}
# Kirim (Timeout agak lama karena Base64 besar)
requests.post(webhook_url, json=webhook_payload, timeout=10)
print(f"✅ Data ID {new_id} terkirim ke N8N (Size foto: {len(str(foto_final))} chars)")
except Exception as e:
print(f"⚠️ Gagal kirim ke N8N: {e}")
# 6. Respon ke Android
return jsonify({
'message': 'Absensi berhasil disimpan',
'data': {
'id_absensi': new_id,
'status': status,
'mata_kuliah': nama_matkul,
'timestamp': timestamp_str
}
}), 201
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/jadwal/today', methods=['GET']) @app.route('/api/jadwal/today', methods=['GET'])
@token_required @token_required
def get_jadwal_today(): def get_jadwal_today():
try: try:
# 1. TRIGGER AUTO ALFA
# Jalankan pengecekan otomatis SEBELUM mengambil data jadwal
jalankan_auto_alfa() jalankan_auto_alfa()
# 2. Ambil Data Jadwal
hari_ini = get_hari_indo() hari_ini = get_hari_indo()
conn = get_db_connection() conn = get_db_connection()
cur = conn.cursor(dictionary=True) cur = conn.cursor(dictionary=True)
cur.execute("SELECT jurusan,semester FROM mahasiswa WHERE id_mahasiswa=%s", cur.execute("SELECT jurusan, semester FROM mahasiswa WHERE id_mahasiswa=%s", (request.user_data['id_mahasiswa'],))
(request.user_data['id_mahasiswa'],))
mhs = cur.fetchone() mhs = cur.fetchone()
cur.execute(""" cur.execute("""
SELECT j.*, m.nama_matkul, m.sks, m.dosen, m.kode_matkul SELECT j.*, m.nama_matkul, m.sks, m.dosen, m.kode_matkul
FROM jadwal_kelas j JOIN mata_kuliah m ON j.id_matkul=m.id_matkul FROM jadwal_kelas j
JOIN mata_kuliah m ON j.id_matkul = m.id_matkul
WHERE j.hari=%s AND j.jurusan=%s AND j.semester=%s WHERE j.hari=%s AND j.jurusan=%s AND j.semester=%s
ORDER BY j.jam_mulai ORDER BY j.jam_mulai
""", (hari_ini, mhs['jurusan'], mhs['semester'])) """, (hari_ini, mhs['jurusan'], mhs['semester']))
jadwal = cur.fetchall() jadwal = cur.fetchall()
for j in jadwal: for j in jadwal:
for col in ['jam_mulai','jam_selesai']: if isinstance(j['jam_mulai'], timedelta): j['jam_mulai'] = str(j['jam_mulai'])
if isinstance(j.get(col), timedelta): if isinstance(j['jam_selesai'], timedelta): j['jam_selesai'] = str(j['jam_selesai'])
j[col] = str(j[col])
# Ambil Status Absensi (HADIR / TIDAK HADIR / SAKIT / IZIN)
cur.execute(""" cur.execute("""
SELECT status FROM absensi SELECT status FROM absensi
WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE() WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()
""", (request.user_data['id_mahasiswa'], j['id_jadwal'])) """, (request.user_data['id_mahasiswa'], j['id_jadwal']))
res = cur.fetchone() res = cur.fetchone()
j['sudah_absen'] = bool(res)
j['status_absensi'] = res['status'] if res else None if res:
j['sudah_absen'] = True
j['status_absensi'] = res['status']
else:
j['sudah_absen'] = False
j['status_absensi'] = None
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({'data': jadwal, 'hari': hari_ini}) return jsonify({'data': jadwal, 'hari': hari_ini})
except Exception as e:
return jsonify({'error': str(e)}), 500
# -------- [FIX 7] LOCATION TOKEN -------- except Exception as e: return jsonify({'error': str(e)}), 500
@app.route('/api/absensi/request-location-token', methods=['POST'])
@token_required
def request_location_token():
"""
Android memanggil endpoint ini tepat setelah GPS fix diterima.
Backend mencatat waktu & koordinat, lalu memberikan token sekali pakai.
Token hanya valid 2 menit dan hanya bisa dipakai 1x.
Alur wajib di Android:
1. Terima GPS fix
2. POST /api/absensi/request-location-token dapat location_token
3. Ambil foto selfie (maks dalam 2 menit)
4. POST /api/absensi/submit + location_token
"""
# Rate limit: maks 10 request token per 5 menit per user
user_id = str(request.user_data['id_mahasiswa'])
if is_rate_limited(f"{user_id}:loc_token", 10, 300):
return jsonify({'error': 'Terlalu banyak permintaan token lokasi'}), 429
try:
data = request.get_json()
lat = data.get('latitude')
lon = data.get('longitude')
if lat is None or lon is None:
return jsonify({'error': 'Koordinat GPS wajib disertakan'}), 400
try:
lat = float(lat)
lon = float(lon)
except (ValueError, TypeError):
return jsonify({'error': 'Koordinat tidak valid'}), 400
# [FIX 8] Jalankan deteksi anomali koordinat
mencurigakan, alasan = deteksi_anomali_koordinat(lat, lon)
if mencurigakan:
return jsonify({'error': f'Koordinat tidak valid: {alasan}'}), 400
# Cek apakah sudah dalam radius kampus
jarak = hitung_jarak_meter(lat, lon, KAMPUS_LATITUDE, KAMPUS_LONGITUDE)
if jarak > RADIUS_METER:
return jsonify({
'error': f'Lokasi Anda terlalu jauh dari kampus ({jarak:.0f}m). Maksimal {RADIUS_METER:.0f}m.',
'jarak_meter': round(jarak, 1)
}), 403
# Bersihkan token lama yang expired
bersihkan_token_expired()
# Buat location token baru
loc_token = secrets.token_hex(32)
_location_tokens[loc_token] = {
'id_mahasiswa': request.user_data['id_mahasiswa'],
'device_id': request.user_data.get('device_id', ''),
'lat': lat,
'lon': lon,
'jarak_meter': round(jarak, 1),
'expires_at': time.time() + LOCATION_TOKEN_TTL
}
print(f"📍 Location token diterbitkan untuk user {user_id} | jarak: {jarak:.1f}m")
return jsonify({
'location_token': loc_token,
'expires_in_seconds': LOCATION_TOKEN_TTL,
'jarak_meter': round(jarak, 1),
'message': f'Token lokasi valid selama {LOCATION_TOKEN_TTL} detik. Segera ambil foto dan submit absensi.'
}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
# -------- ABSENSI SUBMIT --------
@app.route('/api/absensi/submit', methods=['POST'])
@token_required
def submit_absensi():
# [FIX 4] Rate limit
user_id = str(request.user_data['id_mahasiswa'])
if is_rate_limited(f"{user_id}:absensi", 3, 60):
return jsonify({'error': 'Terlalu banyak request, coba lagi sebentar'}), 429
try:
data = request.get_json()
status = data.get('status', 'HADIR')
conn = get_db_connection()
cur = conn.cursor(dictionary=True)
# [FIX 3] Verifikasi device_id
token_device_id = request.user_data.get('device_id', '')
cur.execute("SELECT device_id,nama,jurusan,semester FROM mahasiswa WHERE id_mahasiswa=%s",
(request.user_data['id_mahasiswa'],))
mhs_data = cur.fetchone()
if mhs_data.get('device_id') and mhs_data['device_id'] != token_device_id:
cur.close(); conn.close()
return jsonify({'error': 'Request berasal dari perangkat tidak sah'}), 403
# [FIX 7] Validasi location token — WAJIB ADA
loc_token = data.get('location_token')
if not loc_token:
cur.close(); conn.close()
return jsonify({
'error': 'Location token wajib disertakan. Panggil /api/absensi/request-location-token terlebih dahulu.'
}), 400
token_data = _location_tokens.get(loc_token)
if not token_data:
cur.close(); conn.close()
return jsonify({'error': 'Location token tidak valid atau sudah digunakan'}), 403
if time.time() > token_data['expires_at']:
del _location_tokens[loc_token]
cur.close(); conn.close()
return jsonify({'error': f'Location token sudah kadaluarsa (maks {LOCATION_TOKEN_TTL} detik). Silakan ulangi proses absensi.'}), 403
if token_data['id_mahasiswa'] != request.user_data['id_mahasiswa']:
cur.close(); conn.close()
return jsonify({'error': 'Location token bukan milik Anda'}), 403
if token_data['device_id'] != token_device_id:
cur.close(); conn.close()
return jsonify({'error': 'Location token digunakan dari perangkat berbeda'}), 403
# Ambil koordinat dan jarak yang sudah diverifikasi dari token (bukan dari request body)
# Ini mencegah manipulasi koordinat di tahap submit
lat = token_data['lat']
lon = token_data['lon']
jarak = token_data['jarak_meter']
# Hapus token — ONE TIME USE
del _location_tokens[loc_token]
# [FIX 5] Validasi kepemilikan jadwal
cur.execute("""
SELECT j.id_jadwal,j.jam_mulai,j.jam_selesai,j.jurusan,j.semester,m.nama_matkul
FROM jadwal_kelas j JOIN mata_kuliah m ON j.id_matkul=m.id_matkul
WHERE j.id_jadwal=%s AND j.jurusan=%s AND j.semester=%s
""", (data['id_jadwal'], mhs_data['jurusan'], mhs_data['semester']))
jadwal = cur.fetchone()
if not jadwal:
cur.close(); conn.close()
return jsonify({'error': 'Jadwal tidak valid atau bukan milik kelas Anda'}), 403
# [FIX 6] Validasi jam kelas aktif
jam_sekarang = datetime.now().time()
jam_mulai = (datetime.min + jadwal['jam_mulai']).time() if isinstance(jadwal['jam_mulai'], timedelta) else jadwal['jam_mulai']
jam_selesai = (datetime.min + jadwal['jam_selesai']).time() if isinstance(jadwal['jam_selesai'], timedelta) else jadwal['jam_selesai']
if not (jam_mulai <= jam_sekarang <= jam_selesai):
cur.close(); conn.close()
return jsonify({
'error': f'Absensi hanya bisa dilakukan saat jam kelas aktif ({jam_mulai.strftime("%H:%M")} - {jam_selesai.strftime("%H:%M")})'
}), 403
# [FIX 2] Validasi foto
foto_input = data.get('foto_base64') or data.get('photo')
valid, pesan = validasi_foto(foto_input)
if not valid:
cur.close(); conn.close()
return jsonify({'error': f'Foto tidak valid: {pesan}'}), 400
# Cek double absen
cur.execute(
"SELECT COUNT(*) as c FROM absensi WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()",
(request.user_data['id_mahasiswa'], data['id_jadwal'])
)
if cur.fetchone()['c'] > 0:
cur.close(); conn.close()
return jsonify({'error': 'Anda sudah absen mata kuliah ini hari ini!'}), 400
timestamp_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cur.execute("""
INSERT INTO absensi (id_mahasiswa,npm,nama,id_jadwal,mata_kuliah,
latitude,longitude,jarak_meter,timestamp,photo,foto_base64,status,device_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (request.user_data['id_mahasiswa'], request.user_data['npm'], mhs_data['nama'],
data['id_jadwal'], jadwal['nama_matkul'],
lat, lon, jarak, timestamp_str,
foto_input, foto_input, status, token_device_id))
conn.commit()
new_id = cur.lastrowid
cur.close(); conn.close()
# Kirim ke N8N webhook
try:
requests.post(
"https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254",
json={
"id_absensi": new_id, "npm": request.user_data['npm'],
"nama": mhs_data['nama'], "mata_kuliah": jadwal['nama_matkul'],
"latitude": lat, "longitude": lon, "jarak_meter": jarak,
"timestamp": timestamp_str, "status": status, "foto_base64": foto_input
}, timeout=10
)
except Exception as e:
print(f"⚠️ Gagal kirim ke N8N: {e}")
return jsonify({
'message': 'Absensi berhasil disimpan',
'data': {
'id_absensi': new_id, 'status': status,
'mata_kuliah': jadwal['nama_matkul'],
'jarak_meter': jarak,
'timestamp': timestamp_str
}
}), 201
except Exception as e:
print(f"Error submit absensi: {e}")
return jsonify({'error': str(e)}), 500
# -------- RIWAYAT & FOTO --------
@app.route('/api/absensi/history', methods=['GET']) @app.route('/api/absensi/history', methods=['GET'])
@token_required @token_required
def get_history(): def get_history():
try: connection = get_db_connection()
start_date = request.args.get('start_date') cursor = connection.cursor(dictionary=True)
end_date = request.args.get('end_date') # Join jadwal untuk ambil jam
conn = get_db_connection() cursor.execute("""
cur = conn.cursor(dictionary=True) SELECT a.*, j.jam_mulai, j.jam_selesai
FROM absensi a
query = """ LEFT JOIN jadwal_kelas j ON a.id_jadwal = j.id_jadwal
SELECT a.id_absensi,a.npm,a.nama,a.mata_kuliah,a.latitude,a.longitude, WHERE a.id_mahasiswa = %s ORDER BY a.timestamp DESC
a.jarak_meter,a.timestamp,a.status,a.created_at,j.jam_mulai,j.jam_selesai """, (request.user_data['id_mahasiswa'],))
FROM absensi a LEFT JOIN jadwal_kelas j ON a.id_jadwal=j.id_jadwal history = cursor.fetchall()
WHERE a.id_mahasiswa=%s for item in history:
""" if item['jam_mulai']: item['jam_mulai'] = str(item['jam_mulai'])
params = [request.user_data['id_mahasiswa']] if item['jam_selesai']: item['jam_selesai'] = str(item['jam_selesai'])
if start_date: cursor.close(); connection.close()
query += " AND DATE(a.timestamp)>=%s"; params.append(start_date) return jsonify({'data': history}), 200
if end_date:
query += " AND DATE(a.timestamp)<=%s"; params.append(end_date)
query += " ORDER BY a.timestamp DESC"
cur.execute(query, params)
history = cur.fetchall()
for item in history:
for col in ['jam_mulai','jam_selesai']:
if isinstance(item.get(col), timedelta):
item[col] = str(item[col])
cur.close(); conn.close()
return jsonify({'data': history}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/absensi/photo/<int:id_absensi>', methods=['GET']) @app.route('/api/absensi/photo/<int:id_absensi>', methods=['GET'])
@token_required @token_required
def get_photo(id_absensi): def get_photo(id_absensi):
conn = get_db_connection() connection = get_db_connection()
cur = conn.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
cur.execute( cursor.execute("SELECT foto_base64 FROM absensi WHERE id_absensi = %s", (id_absensi,))
"SELECT foto_base64 FROM absensi WHERE id_absensi=%s AND id_mahasiswa=%s", result = cursor.fetchone()
(id_absensi, request.user_data['id_mahasiswa']) cursor.close(); connection.close()
) if result: return jsonify({'data': result}), 200
result = cur.fetchone() return jsonify({'error': 'Not found'}), 404
cur.close(); conn.close()
if result:
return jsonify({'data': result}), 200
return jsonify({'error': 'Foto tidak ditemukan atau bukan milik Anda'}), 404
# ==================== RUN SERVER ==================== # ==================== RUN SERVER ====================
if __name__ == '__main__': if __name__ == '__main__':
# HAPUS semua kode Scheduler disini agar tidak blocking
print("🚀 Menginisialisasi database...") print("🚀 Menginisialisasi database...")
init_database() init_database()
print("🔒 Security fixes aktif:") print("🌐 Starting Flask server (Auto Alfa Trigger Mode)...")
print(" ✅ [FIX 1] Server-side GPS validation")
print(" ✅ [FIX 2] Server-side foto validation")
print(" ✅ [FIX 3] JWT device binding")
print(" ✅ [FIX 4] Rate limiting")
print(" ✅ [FIX 5] Jadwal ownership validation")
print(" ✅ [FIX 6] Jam kelas aktif validation")
print(" ✅ [FIX 7] Location token (one-time, 2 menit)")
print(" ✅ [FIX 8] Anomali koordinat detection")
print("🌐 Starting Flask server...")
app.run(debug=True, host='0.0.0.0', port=5000) app.run(debug=True, host='0.0.0.0', port=5000)

View File

@ -11,9 +11,6 @@ pluginManagement {
gradlePluginPortal() gradlePluginPortal()
} }
} }
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
}
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {