# SSO Shared Session & Password Expiry — Garis Besar Implementasi

> **Status:** Rancangan — belum ada perubahan kode
> **Branch target:** `feature/sso-password-expiry`
> **Tanggal:** 2026-06-11
> **Berlaku untuk:** HRIS · Ememo · Apps

---

## 1. Latar Belakang

Tiga aplikasi (HRIS, Ememo, Apps) saat ini memiliki login masing-masing meskipun sudah berbagi tabel `users` dan tabel `sessions` di database `opis_db`. Tujuan implementasi ini:

1. **SSO (Single Sign-On)** — user cukup login sekali dan dapat mengakses ketiga aplikasi tanpa login ulang.
2. **Password Expiry** — password wajib diganti setiap 60 hari (± 2 bulan) untuk memenuhi standar keamanan.

---

## 2. Kondisi Saat Ini (As-Is)

| Aspek | Kondisi |
|---|---|
| User table | `opis_db.users` — sudah dipakai bersama |
| Session table | `opis_db.sessions` — sudah dipakai bersama |
| Session cookie name | `opis_session` (dari `APP_NAME=OPIS`) |
| Session domain | `null` — **belum di-set**, sehingga cookie tidak dibaca lintas subdomain |
| App access control | `id_ref_apps` JSON array per user (e.g. `[1, 2, 3]`) |
| Password expiry | **Belum ada** — tidak ada field maupun middleware |
| OAuth/SSO library | **Tidak ada** — hanya session + JWT mobile |

**Kesimpulan:** Infrastruktur sudah 80% siap. Yang kurang hanya konfigurasi `SESSION_DOMAIN` dan penambahan logika password expiry.

---

## 3. Arsitektur Target (To-Be)

```
                        ┌──────────────────────────┐
                        │         opis_db           │
                        │                           │
                        │  users                    │
                        │   └─ password_changed_at  │  ← BARU
                        │  sessions                 │
                        │  password_resets          │
                        └────────────┬─────────────┘
                                     │ shared via
                                     │ SESSION_DOMAIN=.shf.co.id
                    ┌────────────────┼────────────────┐
                    ▼                ▼                 ▼
             hris.shf.co.id   ememo.shf.co.id   apps.shf.co.id
              (app_id = 2)     (app_id = ?)      (app_id = ?)
                    │                │                 │
                    └────────────────┴─────────────────┘
                         Semua app cek:
                         1. id_ref_apps → apakah user boleh akses app ini?
                         2. password_changed_at → apakah password sudah expired?
```

**Flow Login SSO:**
```
User buka hris.shf.co.id
  → belum ada session → redirect ke /login
  → input email & password → session dibuat di opis_db.sessions
  → cookie opis_session di-set untuk domain .shf.co.id

User buka ememo.shf.co.id
  → browser kirim cookie opis_session (karena domain cocok)
  → session ditemukan di opis_db.sessions → langsung masuk
  → middleware cek id_ref_apps → izin akses Ememo?
```

---

## 4. Daftar Perubahan

### 4.1 Database Migration

**Satu migration, dijalankan sekali, berlaku untuk semua app** (karena semua app pakai `opis_db` yang sama).

```
File: database/migrations/YYYY_MM_DD_000001_add_password_changed_at_to_users_table.php
Koneksi: opis_db
Table: users
Field baru: password_changed_at TIMESTAMP NULL
```

```php
Schema::connection('opis_db')->table('users', function (Blueprint $table) {
    $table->timestamp('password_changed_at')->nullable()->after('password');
});
```

> **Catatan:** Setelah migration, jalankan query berikut agar user lama tidak langsung ter-expired:
> ```sql
> UPDATE users SET password_changed_at = NOW() WHERE password_changed_at IS NULL;
> ```

---

### 4.2 Perubahan Model `User.php`

**File:** `app/User.php`
**Berlaku di:** HRIS (sumber). Ememo & Apps — sync model yang sama atau update masing-masing.

Tambahkan di `$fillable`:
```php
'password_changed_at',
```

Tambahkan di `$casts`:
```php
'password_changed_at' => 'datetime',
```

Tambahkan helper method:
```php
public function isPasswordExpired(): bool
{
    $lastChanged = $this->password_changed_at ?? $this->created_at;
    return \Carbon\Carbon::now()->diffInDays($lastChanged) >= 60;
}
```

---

### 4.3 Middleware `CheckPasswordExpiry`

**File baru:** `app/Http/Middleware/CheckPasswordExpiry.php`
**Berlaku di:** HRIS, Ememo, Apps (file yang sama, deploy ke masing-masing app)

```
Logika:
  IF user tidak login → lewati (biarkan auth middleware yang handle)
  IF route adalah password.change / password.update / logout → lewati
  IF password_changed_at belum diisi → anggap expired
  IF selisih hari >= 60 → redirect ke halaman ganti password
  ELSE → lanjutkan request
```

```php
namespace App\Http\Middleware;

use Closure;
use Carbon\Carbon;

class CheckPasswordExpiry
{
    const EXPIRY_DAYS = 60;

    public function handle($request, Closure $next)
    {
        $user = auth()->user();

        if (!$user) {
            return $next($request);
        }

        if ($request->routeIs('password.change', 'password.update', 'logout')) {
            return $next($request);
        }

        if ($user->isPasswordExpired()) {
            if ($request->expectsJson()) {
                return response()->json([
                    'message' => 'Password kadaluarsa. Silakan ganti password Anda.',
                    'password_expired' => true,
                    'redirect' => route('password.change'),
                ], 403);
            }

            return redirect()->route('password.change')
                ->with('warning', 'Password Anda sudah kadaluarsa (lebih dari 60 hari). Silakan ganti password untuk melanjutkan.');
        }

        return $next($request);
    }
}
```

---

### 4.4 Registrasi Middleware di `Kernel.php`

**File:** `app/Http/Kernel.php`
**Berlaku di:** HRIS, Ememo, Apps (masing-masing)

```php
protected $routeMiddleware = [
    // ... existing ...
    'password.expiry' => \App\Http\Middleware\CheckPasswordExpiry::class,
];
```

---

### 4.5 Penerapan Middleware di Routes

**File:** `routes/web.php`
**Berlaku di:** HRIS, Ememo, Apps (masing-masing, sesuaikan route group yang ada)

```php
// Tambahkan 'password.expiry' ke group route yang sudah dilindungi 'auth'
Route::middleware(['auth', 'password.expiry'])->group(function () {
    // semua protected routes tetap sama
});
```

---

### 4.6 Update Semua Titik Perubahan Password

Setiap kali password berhasil diubah, wajib update `password_changed_at = now()`.

**Titik yang perlu diupdate di masing-masing app:**

| No | File | Method | Aksi |
|---|---|---|---|
| 1 | `CustomResetPasswordController.php` | `updatePassword()` | Tambah `password_changed_at => now()` |
| 2 | `RegisterController.php` | `create()` | Tambah `password_changed_at => now()` |
| 3 | Controller profile/password change | method update password | Tambah `password_changed_at => now()` |

Contoh implementasi:
```php
$user->update([
    'password'            => Hash::make($request->new_password),
    'password_changed_at' => now(),
]);
```

---

### 4.7 Konfigurasi `.env` — SSO Session Domain

**Berlaku di: semua 3 aplikasi** — nilai harus identik.

```dotenv
# Session sharing (SSO)
SESSION_DRIVER=database
SESSION_CONNECTION=opis_db
SESSION_LIFETIME=120
SESSION_DOMAIN=.shf.co.id        # ← WAJIB diisi, sesuaikan domain production
SESSION_COOKIE=opis_session       # ← WAJIB sama persis di semua app

# Pastikan APP_NAME sama agar nama cookie konsisten jika tidak set SESSION_COOKIE
APP_NAME=OPIS
```

> **Penting untuk local development:** Set `SESSION_DOMAIN=null` atau kosongkan agar tidak konflik.
> Gunakan nilai `.shf.co.id` hanya di environment UAT dan Production.

---

### 4.8 Halaman Ganti Password (UI)

Masing-masing app perlu memiliki halaman ganti password yang dapat diakses meski password expired.

**Route yang dibutuhkan:**
```php
Route::middleware(['auth'])->group(function () {
    // Tanpa password.expiry agar user yang expired tetap bisa akses
    Route::get('/password/change', [PasswordChangeController::class, 'show'])
        ->name('password.change');
    Route::post('/password/change', [PasswordChangeController::class, 'update'])
        ->name('password.update');
});
```

**Validasi form ganti password:**
```php
$request->validate([
    'current_password' => ['required', 'current_password'],
    'new_password'     => ['required', 'min:8', 'confirmed'],
]);
```

---

### 4.9 Notifikasi Email (Opsional — Best Practice)

Kirim email reminder 7 hari sebelum password expired.

**File baru:** `app/Console/Commands/NotifyPasswordExpiry.php`

```
Logic:
  Query user WHERE password_changed_at <= NOW() - 53 hari
  AND password_changed_at > NOW() - 60 hari
  (artinya: yang sudah 53-59 hari, belum expired tapi hampir)
  Kirim email notifikasi
```

**Daftarkan di scheduler:**
```php
// app/Console/Kernel.php
$schedule->command('password:notify-expiry')->dailyAt('08:00');
```

---

## 5. Urutan Implementasi (Checklist)

Implementasi dilakukan **bertahap** dengan urutan berikut. Selesaikan satu tahap sebelum lanjut ke tahap berikutnya.

### Tahap 1 — Database & Model (Shared, lakukan sekali)
- [ ] Buat migration `add_password_changed_at_to_users_table`
- [ ] Jalankan migration di UAT
- [ ] Jalankan query backfill `UPDATE users SET password_changed_at = NOW()`
- [ ] Update `User.php` — tambah fillable, casts, dan method `isPasswordExpired()`

### Tahap 2 — Password Expiry (Per-app, deploy ke HRIS dulu sebagai pilot)
- [ ] Buat `CheckPasswordExpiry` middleware
- [ ] Daftarkan di `Kernel.php`
- [ ] Terapkan di `routes/web.php`
- [ ] Update `CustomResetPasswordController@updatePassword` — tambah `password_changed_at`
- [ ] Update `RegisterController@create` — tambah `password_changed_at`
- [ ] Buat/update halaman ganti password + route `password.change` & `password.update`
- [ ] Test: login dengan user yang `password_changed_at` diset 61 hari lalu → harus redirect

### Tahap 3 — SSO Session Domain (Deploy ke semua app bersamaan)
- [ ] Konfirmasi semua app berjalan di subdomain `.shf.co.id`
- [ ] Update `.env` HRIS — set `SESSION_DOMAIN` dan `SESSION_COOKIE`
- [ ] Update `.env` Ememo — set `SESSION_DOMAIN` dan `SESSION_COOKIE` (nilai sama)
- [ ] Update `.env` Apps — set `SESSION_DOMAIN` dan `SESSION_COOKIE` (nilai sama)
- [ ] Test: login di HRIS → buka Ememo → harus langsung masuk tanpa login ulang
- [ ] Test: logout di salah satu app → semua app harus ikut logout

### Tahap 4 — Deploy Password Expiry ke Ememo & Apps
- [ ] Copy middleware `CheckPasswordExpiry` ke Ememo
- [ ] Copy middleware `CheckPasswordExpiry` ke Apps
- [ ] Terapkan di route masing-masing
- [ ] Update titik perubahan password di masing-masing app
- [ ] Buat halaman ganti password di masing-masing app

### Tahap 5 — Notifikasi (Opsional)
- [ ] Buat command `NotifyPasswordExpiry`
- [ ] Buat Mailable/Notification class
- [ ] Daftarkan di scheduler
- [ ] Test kirim email

---

## 6. Catatan Penting & Risiko

| Topik | Catatan |
|---|---|
| **Backfill data** | Wajib dijalankan setelah migration. Jika tidak, semua user lama akan langsung ter-expired karena `password_changed_at = NULL` |
| **Session domain local** | Jangan set `SESSION_DOMAIN` di `.env` local/development. SSO hanya aktif di UAT/Production |
| **Logout SSO** | Pastikan logout menghapus session dari `opis_db.sessions` (bukan hanya invalidate guard). Gunakan `Session::flush()` atau `Auth::logoutOtherDevices()` |
| **JWT mobile** | Password expiry cukup dicek saat `apiLogin()`. Jika password expired, tolak login dengan response 403 + `password_expired: true`. Token yang sudah aktif tidak perlu di-revoke otomatis |
| **HTTPS** | Setelah SSO aktif di production, set `SESSION_SECURE_COOKIE=true` di `.env` agar cookie hanya dikirim via HTTPS |
| **App ID** | Pastikan setiap app memiliki ID unik yang konsisten di kolom `id_ref_apps`. Dokumentasikan mapping: HRIS=2, Ememo=?, Apps=? |
| **Password history** | Jika ke depan dibutuhkan larangan menggunakan password lama, perlu tabel `password_history` tambahan. Tidak termasuk scope ini |

---

## 7. Mapping App ID `id_ref_apps`

> Lengkapi tabel ini sebelum memulai implementasi Tahap 3.

| Aplikasi | App ID | Keterangan |
|---|---|---|
| HRIS | `2` | Sudah dikonfirmasi dari kode |
| Ememo | `?` | **Perlu dikonfirmasi** |
| Apps | `?` | **Perlu dikonfirmasi** |

---

## 8. Referensi File

| Komponen | Path (HRIS) |
|---|---|
| User model | `app/User.php` |
| Authenticate middleware | `app/Http/Middleware/Authenticate.php` |
| HTTP Kernel | `app/Http/Kernel.php` |
| Login controller | `app/Http/Controllers/Auth/LoginController.php` |
| Custom reset password | `app/Http/Controllers/Auth/CustomResetPasswordController.php` |
| Register controller | `app/Http/Controllers/Auth/RegisterController.php` |
| Web routes | `routes/web.php` |
| Session config | `config/session.php` |
| Auth config | `config/auth.php` |
| Environment | `.env` |
