Panduan Pasukan Lucee MY

JWT dalam Lucee: terbit, sahkan dan batalkan token

Membina JWT HS256 dalam CFML tanpa pustaka luar: base64url, tandatangan HMAC, pengesahan exp, penolakan alg none dan senarai pembatalan jti dalam MySQL.

JWT dalam Lucee: terbit, sahkan dan batalkan token
0
Kandungan

JWT dalam Lucee: terbit, sahkan dan batalkan token

JWT (JSON Web Token) ialah token tanpa keadaan yang membawa identiti pengguna dan tandatangannya dalam satu rentetan. Anda tidak perlukan pustaka luar untuk membinanya dalam Lucee: hmac() untuk tandatangan, fungsi tersuai untuk base64url, dan satu jadual MySQL untuk senarai pembatalan. Setiap blok kod di bawah dijalankan dan lulus pada Lucee 7.1.

Struktur header.payload.signature

Sebuah JWT terdiri daripada tiga bahagian yang dipisahkan titik, setiap satu dienkod base64url. Header membawa metadata, biasanya {"alg":"HS256","typ":"JWT"}. Payload membawa klaim seperti sub, iat, exp dan jti, dan ia TIDAK disulitkan. Signature ialah HMAC-SHA256 ke atas header.payload menggunakan kunci rahsia, mengunci dua bahagian pertama.

JWT memakai base64url, bukan base64 biasa: tiada padding =, dan + serta / diganti dengan - dan _, supaya selamat dalam URL dan pengepala Authorization. Lucee tiada fungsi base64url terbina dalam, jadi kita bina sendiri menggunakan enjin Java.

Pengekodan base64url tanpa padding

toBase64() menghasilkan base64 standard berpadding yang memakai + dan /, tidak sesuai untuk JWT. Jalan paling bersih ialah memanggil java.util.Base64 melalui interop Java:

function b64url(teks) {
    return createObject("java", "java.util.Base64").getUrlEncoder().withoutPadding()
        .encodeToString(charsetDecode(teks, "utf-8"));
}
function dariB64url(segmen) {
    return charsetEncode(createObject("java", "java.util.Base64").getUrlDecoder().decode(segmen), "utf-8");
}
asal = "hello dunia";
writeOutput("kod: " & b64url(asal) & "<br>");
writeOutput("balik: " & dariB64url(b64url(asal)) & "<br>");

charsetDecode menukar rentetan kepada bait UTF-8, dan withoutPadding() membuang = di hujung. Arah sebaliknya memakai getUrlDecoder().

Tandatangan HMAC-SHA256 melalui hmac()

hmac() memulangkan rentetan heksadesimal, bukan bait mentah. Untuk tandatangan JWT yang standard, tukar heks itu kembali kepada bait dengan binaryDecode(..., "hex"), kemudian enkod bait kepada base64url. Tandatangan dikira ke atas header.payload:

function b64url(teks) {
    return createObject("java", "java.util.Base64").getUrlEncoder().withoutPadding()
        .encodeToString(charsetDecode(teks, "utf-8"));
}
function b64urlBait(bait) {
    return createObject("java", "java.util.Base64").getUrlEncoder().withoutPadding().encodeToString(bait);
}
rahsia = "kunci-hs256-sangat-panjang-dan-rahsia";
headerJSON = '{"alg":"HS256","typ":"JWT"}';
payload = { "sub": "123", "iat": int(now().getTime() / 1000), "exp": int(now().getTime() / 1000) + 3600 };
bh = b64url(headerJSON);
bp = b64url(serializeJSON(payload));
tandatangan = b64urlBait(binaryDecode(hmac(bh & "." & bp, rahsia, "HMACSHA256"), "hex"));
token = bh & "." & bp & "." & tandatangan;
writeOutput("token: " & token & "<br>");

Dua perangkap penting. Pertama, serializeJSON() menukar kunci struct tidak berpetik kepada HURUF BESAR, jadi klaim mesti ditulis dengan kunci berpetik seperti "sub" dan "exp". Kedua, header ditulis sebagai rentetan literal supaya serasi dengan pustaka JWT bahasa lain. Simpan kunci rahsia di luar kod, seperti dalam panduan mengurus rahsia CFML.

Sahkan token: tandatangan, klaim dan penolakan none

Pengesahan mesti memeriksa mengikut tertib: tiga bahagian, algoritma none ditolak serta-merta, tandatangan padan, dan klaim masa sah. exp bermakna token luput, nbf bermakna token belum aktif, dan iat merekodkan masa terbitan.

Tandatangan mesti dibandingkan secara constant-time, bukan dengan == atau compareNoCase(). == berhenti pada aksara pertama berbeza dan membocorkan kedudukan aksara yang betul, manakala compareNoCase() mengabaikan huruf besar atau kecil, lubang keselamatan kerana tandatangan peka huruf. Fungsi tandatanganSama() di bawah memakai MessageDigest.isEqual() yang tempohnya tidak bergantung pada kandungan:

function b64url(teks) {
    return createObject("java", "java.util.Base64").getUrlEncoder().withoutPadding()
        .encodeToString(charsetDecode(teks, "utf-8"));
}
function b64urlBait(bait) {
    return createObject("java", "java.util.Base64").getUrlEncoder().withoutPadding().encodeToString(bait);
}
function dariB64url(segmen) {
    return charsetEncode(createObject("java", "java.util.Base64").getUrlDecoder().decode(segmen), "utf-8");
}
function tandatanganSama(a, b) {
    return createObject("java", "java.security.MessageDigest").isEqual(
        charsetDecode(a, "utf-8"), charsetDecode(b, "utf-8"));
}
function sahkanToken(token, rahsia) {
    bhg = listToArray(token, ".", true);
    if (arrayLen(bhg) != 3) { return { sah: false, sebab: "bukan 3 bahagian" }; }
    header = deserializeJSON(dariB64url(bhg[1]));
    if (header.alg == "none") { return { sah: false, sebab: "algoritma none ditolak" }; }
    if (header.alg != "HS256") { return { sah: false, sebab: "algoritma tidak disokong" }; }
    dijangkakan = b64urlBait(binaryDecode(hmac(bhg[1] & "." & bhg[2], rahsia, "HMACSHA256"), "hex"));
    if (!tandatanganSama(dijangkakan, bhg[3])) { return { sah: false, sebab: "tandatangan tidak padan" }; }
    klaim = deserializeJSON(dariB64url(bhg[2]));
    kini = int(now().getTime() / 1000);
    if (structKeyExists(klaim, "exp") && klaim.exp < kini) { return { sah: false, sebab: "token luput" }; }
    if (structKeyExists(klaim, "nbf") && klaim.nbf > kini) { return { sah: false, sebab: "token belum aktif" }; }
    return { sah: true, klaim: klaim };
}
rahsia = "kunci-hs256-sangat-panjang-dan-rahsia";
bh = b64url('{"alg":"HS256","typ":"JWT"}');
bp = b64url(serializeJSON({ "sub": "123", "exp": int(now().getTime() / 1000) + 3600 }));
tok = bh & "." & bp & "." & b64urlBait(binaryDecode(hmac(bh & "." & bp, rahsia, "HMACSHA256"), "hex"));
writeOutput("sah: " & sahkanToken(tok, rahsia).sah & "<br>");
writeOutput("diusik: " & sahkanToken(bh & "." & bp & ".AAAA", rahsia).sebab & "<br>");
writeOutput("none: " & sahkanToken(b64url('{"alg":"none","typ":"JWT"}') & "." & bp & ".", rahsia).sebab & "<br>");

listToArray(token, ".", true) mengekalkan elemen kosong, supaya token serangan alg:none yang berakhir dengan titik tidak disalah baca sebagai dua bahagian. Penolakan none mesti berlaku sebelum pemeriksaan lain, kerana serangan klasik menukar alg kepada none lalu membuang tandatangan.

Menyahkod payload tanpa mengesahkan tandatangan

JWT hanya dienkod, bukan disulitkan, jadi sesiapa yang memegang token boleh membaca payload tanpa kunci:

function b64url(teks) {
    return createObject("java", "java.util.Base64").getUrlEncoder().withoutPadding()
        .encodeToString(charsetDecode(teks, "utf-8"));
}
function dariB64url(segmen) {
    return charsetEncode(createObject("java", "java.util.Base64").getUrlDecoder().decode(segmen), "utf-8");
}
// Penyerang menukar klaim peranan, kemudian menandatangani semula TANPA kunci
tokenPalsu = b64url('{"alg":"HS256","typ":"JWT"}') & "." & b64url('{"sub":"123","peranan":"admin"}') & ".tandatangan-rekaan";
klaim = deserializeJSON(dariB64url(listToArray(tokenPalsu, ".")[2]));
writeOutput("peranan (diubah penyerang): " & klaim.peranan & "<br>");

Contoh ini membuktikan payload boleh dibaca tanpa kunci, dan penyerang boleh membina token palsu berklaim peranan: admin yang memberikan akses pentadbir. Maka jangan simpan rahsia dalam klaim, dan sentiasa sahkan tandatangan sebelum mempercayai payload.

Senarai pembatalan (jti) dalam jadual MySQL

Kerana JWT tanpa keadaan, ia tidak boleh ditarik balik sebelum exp tamat; token yang dicuri selepas log keluar kekal sah sehingga luput. Penyelesaian piawai ialah klaim jti (JWT ID) yang unik, disimpan dalam jadual pembatalan, dan pengesahan menolak token yang jti-nya ada di dalamnya:

queryExecute("
    CREATE TABLE IF NOT EXISTS token_dibatal (
        jti VARCHAR(64) PRIMARY KEY,
        dibatalkan TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
", [], { datasource: "kedai" });
jti = createUUID();
queryExecute("INSERT INTO token_dibatal (jti) VALUES (?)", [jti], { datasource: "kedai" });
senarai = queryExecute("SELECT jti FROM token_dibatal WHERE jti = ?", [jti], { datasource: "kedai" });
writeOutput("dalam senarai pembatalan: " & (senarai.recordCount == 1) & "<br>");
queryExecute("DELETE FROM token_dibatal WHERE jti = ?", [jti], { datasource: "kedai" });

Setiap nilai diikat sebagai parameter dengan ?, jadi tiada risiko suntikan SQL. Semak jadual ini di dalam sahkanToken() dan bersihkan baris selepas exp tamat.

Endpoint pengesahan lengkap

Aliran penuh: terbit token selepas log masuk, semak senarai pembatalan semasa mengesahkan, dan batalkan apabila log keluar:

function b64url(teks) {
    return createObject("java", "java.util.Base64").getUrlEncoder().withoutPadding()
        .encodeToString(charsetDecode(teks, "utf-8"));
}
function b64urlBait(bait) {
    return createObject("java", "java.util.Base64").getUrlEncoder().withoutPadding().encodeToString(bait);
}
function dariB64url(segmen) {
    return charsetEncode(createObject("java", "java.util.Base64").getUrlDecoder().decode(segmen), "utf-8");
}
function tandatanganSama(a, b) {
    return createObject("java", "java.security.MessageDigest").isEqual(
        charsetDecode(a, "utf-8"), charsetDecode(b, "utf-8"));
}
function terbitToken(pengguna, rahsia, jti) {
    klaim = { "sub": pengguna.id, "nama": pengguna.nama, "jti": jti,
        "iat": int(now().getTime() / 1000), "exp": int(now().getTime() / 1000) + 3600 };
    bh = b64url('{"alg":"HS256","typ":"JWT"}');
    bp = b64url(serializeJSON(klaim));
    return bh & "." & bp & "." & b64urlBait(binaryDecode(hmac(bh & "." & bp, rahsia, "HMACSHA256"), "hex"));
}
function sahkanToken(token, rahsia) {
    bhg = listToArray(token, ".", true);
    if (arrayLen(bhg) != 3) { return { sah: false, sebab: "bukan 3 bahagian" }; }
    header = deserializeJSON(dariB64url(bhg[1]));
    if (header.alg == "none" || header.alg != "HS256") { return { sah: false, sebab: "algoritma ditolak" }; }
    dijangkakan = b64urlBait(binaryDecode(hmac(bhg[1] & "." & bhg[2], rahsia, "HMACSHA256"), "hex"));
    if (!tandatanganSama(dijangkakan, bhg[3])) { return { sah: false, sebab: "tandatangan tidak padan" }; }
    klaim = deserializeJSON(dariB64url(bhg[2]));
    kini = int(now().getTime() / 1000);
    if (structKeyExists(klaim, "exp") && klaim.exp < kini) { return { sah: false, sebab: "token luput" }; }
    if (structKeyExists(klaim, "jti")) {
        senarai = queryExecute("SELECT jti FROM token_dibatal WHERE jti = ?", [klaim.jti], { datasource: "kedai" });
        if (senarai.recordCount > 0) { return { sah: false, sebab: "token dibatalkan" }; }
    }
    return { sah: true, klaim: klaim };
}
function batalToken(jti) {
    queryExecute("INSERT INTO token_dibatal (jti) VALUES (?)", [jti], { datasource: "kedai" });
}
// jadual token_dibatal sudah dicipta dalam bahagian senarai pembatalan
rahsia = "kunci-hs256-sangat-panjang-dan-rahsia";
pengguna = { "id": "123", "nama": "Ali" };
jti = createUUID();
token = terbitToken(pengguna, rahsia, jti);
writeOutput("pengesahan pertama: " & sahkanToken(token, rahsia).sah & "<br>");
batalToken(jti);
writeOutput("selepas batal: " & sahkanToken(token, rahsia).sebab & "<br>");
queryExecute("DELETE FROM token_dibatal WHERE jti = ?", [jti], { datasource: "kedai" });

Aliran ini mudah disambungkan ke laluan REST: pelayan mengembalikan token selepas log masuk, dan pelanggan menghantarnya semula dalam pengepala Authorization: Bearer <token>. Corak laluan dan cara memulangkan JSON diterangkan dalam panduan REST API Lucee.

JWT berbanding sesi pelayan

AspekJWTSesi pelayan
Simpanan keadaanDalam tokenDi pelayan
SkalaMudah, tiada storan kongsiPerlu storan sesi kongsi
Pembatalan serta-mertaPerlu senarai jtiPadam sesi, terus berkesan
Muatan setiap permintaanLebih besarKuki kecil

JWT sesuai untuk API yang melayani banyak pelanggan; sesi pelayan lebih mudah untuk aplikasi monolitik. Untuk pengurusan sesi Lucee, rujuk panduan pengurusan sesi.

Keselamatan tambahan

Tandatangan sahaja tidak mencukupi untuk aplikasi yang terdedah. Had kadar log masuk menghalang serangan tekaan kata laluan, seperti dalam panduan melindungi borang log masuk. Perlindungan XSS dan CSRF menghalang token dicuri, seperti dalam panduan keselamatan aplikasi web CFML. Gabungkan juga kata laluan berhash dan dua faktor dalam panduan pengesahan pengguna CFML.

Sumber

Soalan Lazim

Adakah JWT disulitkan?

Tidak. JWT hanya dienkod dengan base64url, bukan disulitkan, jadi sesiapa yang memegang token boleh membaca header dan payload. Ia selamat kerana tandatangan menghalang pengubahan, bukan kerana kandungannya tersembunyi.

Kenapa perlu base64url dan bukan base64 biasa?

Base64 standard memakai +, / dan padding =, yang boleh memecahkan URL. Base64url menggantikannya dengan - dan _ serta membuang padding, jadi selamat dalam URL dan pengepala Authorization.

Apa perbezaan antara iat, exp dan nbf?

iat (issued at) merekodkan masa token diterbitkan, exp (expiration) menandakan masa token luput, manakala nbf (not before) menandakan masa token baru mula sah, semuanya dalam saat epoch Unix.

Kenapa algoritma none mesti ditolak?

Algoritma none membenarkan token tanpa tandatangan. Penyerang boleh menukar alg kepada none, membuang tandatangan, dan mengubah klaim sesuka hati, lalu pemeriksa yang tidak menolaknya akan menerima token itu sebagai sah.

Bagaimana saya membatalkan token sebelum ia luput?

Simpan klaim jti yang unik dalam setiap token, dan simpan jti token yang dibatalkan dalam jadual pembatalan. Semasa mengesahkan, tolak token yang jti-nya wujud dalam jadual itu.

0 komen

0 komen

Log masuk untuk menjawab atau memberi komen.

Log masuk