← API notifikasi JS Staff

接入"使用JS Staff登录"

员工使用其JS Staff账户登录你的应用。你的应用永远无法获取其密码。

你将获得

{
  "user_id": 3,
  "nama": "Sela",
  "email": "sela@contoh.com",
  "peran": "staff",
  "tenant_id": 1,
  "tenant_nama": "Jidanshoppu",
  "tenant_slug": "jidanshoppu"
}

仅此而已。peran(角色)为 staffsupervisorowner。你的应用无法读取任何人的密码、工资、考勤、预支或银行账户。

为什么用重定向,而不是"提交邮箱+密码"的接口。那样的接口会使本门户所依赖的新设备 OTP 形同虚设。OTP 的全部意义在于仅凭密码不足以登录——一旦有一扇门只需要密码,密码窃贼就会直接走那扇门。

使用重定向后,就不存在这样的门:登录始终发生在我们的页面上,并附带 OTP 与可信设备机制。

流程

[1] 员工在你的应用中点击"使用 JS Staff 登录" ↓ [2] 你把他重定向到 https://staff.jidanshoppu.com/sso/authorize.php?... ↓ (他在那里登录 + 新设备则需 OTP + 同意屏幕) [3] 他返回到 sso-callback.php?code=...&state=... ↓ [4] 你的服务器在 /sso/token.php 用 code + secret 换取身份 ← 服务器对服务器 ↓ [5] 得到身份信息 → 由你自己创建会话

1 · 请求这两个值

向 JS Staff 平台管理员申请(他们在 /super/apps.php 中注册):

示例
client_idapp_a1b2c3d4e5f6a7b8
client_secretsec_1a2b3c… ——只显示一次

注册时你必须提供你的 redirect URI。规则很严格:

为什么要这么严格。这是整个 SSO 流程中最重要的一道防线。哪怕稍微放宽一点——前缀、子域名、通配符——攻击者只需向你的员工发送一个带有自己回调地址的授权链接即可。受害者照常在我们真实的页面上登录,看起来毫无异常,而授权码却会落到攻击者的服务器上。

2 · 配置

<?php
// sso-config.php — JANGAN di-commit. Isi dari env var.
const SSO_BASE      = 'https://staff.jidanshoppu.com';
const SSO_CLIENT_ID = 'app_xxxxxxxxxxxxxxxx';
const SSO_SECRET    = 'sec_xxxxxxxxxxxxxxxx';
const SSO_REDIRECT  = 'https://order.jidanshoppu.com/sso-callback.php';

3 · 登录按钮

<?php
// sso-mulai.php
require 'sso-config.php';
session_start();

/*
 * `state` WAJIB. Acak, disimpan di sesi, dicocokkan lagi di callback.
 * Tanpa ini, penyerang bisa mengirim link callback berisi kode MILIK DIA ke korban —
 * korban jadi login sebagai akun penyerang, lalu mengetik data ke akun itu tanpa sadar.
 */
$_SESSION['sso_state'] = bin2hex(random_bytes(16));

$url = SSO_BASE . '/sso/authorize.php?' . http_build_query([
    'client_id'    => SSO_CLIENT_ID,
    'redirect_uri' => SSO_REDIRECT,
    'state'        => $_SESSION['sso_state'],
]);

header('Location: ' . $url);
exit;

一个按钮就够了: <a href="/sso-mulai.php">Masuk pakai JS Staff</a>

4 · 回调

<?php
// sso-callback.php
require 'sso-config.php';
session_start();

// --- Orangnya menolak di layar persetujuan ---
if (isset($_GET['error'])) {
    exit('Login dibatalkan.');
}

// --- Cocokkan state DULU, sebelum menyentuh code ---
$state = (string)($_GET['state'] ?? '');

if ($state === ''
    || !isset($_SESSION['sso_state'])
    || !hash_equals($_SESSION['sso_state'], $state)) {
    // hash_equals, bukan ===: perbandingan biasa berhenti di huruf pertama yang beda,
    // dan selisih waktunya bisa dipakai menebak nilainya.
    exit('State tidak cocok. Ulangi dari awal.');
}
unset($_SESSION['sso_state']);   // sekali pakai

$code = (string)($_GET['code'] ?? '');
if ($code === '') {
    exit('Kode tidak ada.');
}

// --- Tukar kode jadi identitas (SERVER ke server) ---
$ch = curl_init(SSO_BASE . '/sso/token.php');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 10,
    CURLOPT_POSTFIELDS     => http_build_query([
        'client_id'     => SSO_CLIENT_ID,
        'client_secret' => SSO_SECRET,
        'code'          => $code,
        'redirect_uri'  => SSO_REDIRECT,   // harus sama dengan langkah 3
    ]),
]);

$body = curl_exec($ch);
$kode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($kode !== 200) {
    // Jangan tampilkan $body apa adanya ke pengguna — itu pesan untuk kamu, bukan dia.
    error_log('SSO gagal: ' . $kode . ' ' . $body);
    exit('Login gagal. Coba lagi.');
}

$data = json_decode((string)$body, true);
if (!is_array($data) || empty($data['ok'])) {
    exit('Login gagal. Coba lagi.');
}

$u = $data['user'];

// --- Mulai dari sini urusan kamu ---
session_regenerate_id(true);   // cegah session fixation

$_SESSION['jss_user_id']   = $u['user_id'];
$_SESSION['jss_tenant_id'] = $u['tenant_id'];
$_SESSION['nama']          = $u['nama'];
$_SESSION['peran']         = $u['peran'];

header('Location: /app.php');
exit;
此步骤必须由服务器完成,而非浏览器。client_secret 经过浏览器,等同于把这个密钥分享给所有人。

请立即兑换该 code。它的有效期为60 秒,且只能使用一次——它会经过浏览器 URL,而 URL 会留存在浏览记录、服务器日志和 Referer 请求头中。

5 · 映射到你这边的账户

user_id 是全局唯一的,tenant_id 标识所属公司。两者都是稳定的——即使名称改变,它们也永远不会变。

不要用邮箱进行匹配。邮箱可以被老板修改,而用邮箱匹配意味着一旦邮箱被更改,账户就可能悄无声息地易主。请保存 jss_user_id
ALTER TABLE staff ADD COLUMN jss_user_id   INTEGER NULL;
ALTER TABLE staff ADD COLUMN jss_tenant_id INTEGER NULL;
CREATE UNIQUE INDEX uq_staff_jss ON staff (jss_user_id);

首次流程:

  1. 查找 jss_user_id = :user_id 对应的账户 → 找到了?登录。
  2. 没找到?将 tenant_slug 与你这边的店铺匹配,创建员工账户,并保存其 jss_user_id
  3. tenant_slug 无法识别?拒绝。不要悄悄地根据 SSO 数据创建新店铺——任何拥有 JS Staff 账户的人都能触发这种操作。

必需项与非必需项

必需

非必需

如果失败

现象通常原因
出现"返回地址不匹配"页面 redirect_uri 与注册时的不同。检查末尾斜杠、http 与 https、查询字符串。
出现"未知应用"页面 client_id 错误,或该应用已被平台管理员撤销。
/sso/token.php 返回 401 code 已过期(超过 60 秒)、已被使用、client_secret 错误,或 redirect_uri 与第 3 步不一致。
返回时带有 ?error=access_denied 员工点击了拒绝。这不是缺陷。

401 有意对所有原因使用相同的信息——区分"code 错误"和"code 已被使用"会给攻击者一种探测哪些 code 曾经存在过的手段,而对集成正确的人也没有任何帮助。请逐一检查上面这四种情况。

测试

  1. 请平台管理员为你注册一个专门用于测试的第二个应用。
  2. 使用真实的员工账户登录。
  3. 同意屏幕应该只出现一次。第二次应该直接通过。
  4. 我的个人资料 → 允许使用你账户的应用中撤销授权 → 同意屏幕应该再次出现。