[Dịch] Những giải pháp cho vấn đề cross-domain của frontend

Đăng lúc 09/08/2018

Cũng như các bài dịch trước, sau khi đi làm về, mình lang thang trên segmentfault tìm xem có gì đó đọc không để đỡ tối cổ. Tình cờ mình tìm được một bài về Vue, nói về vấn đề mình đang gặp công ty. Đọc xong thấy khá hay, nên mình tìm thử xem tác giả còn viết gì không thì ra được bài này. Một bài khá thú vị của tác giả 周绪南 (Châu Tự Nam). Một bạn developer còn khá trẻ (bạn sinh năm 1996). Thấy hay và có ích nên mình dịch sang tiếng Việt để chia sẽ cho mọi người. Các bạn có thể vào trang Github của tác giả để tham khảo các source code của bạn ấy nhé.

Link bài gốc: 前端跨域解决方案

Browser Same Origin Policy

Browser Same Origin Policy là gì?

Same Origin Policy là một cơ chế bảo mật của trình duyệt.

Same Origin Policy có thể giải thích là cách để hạn chế những tài liệu hoặc mã được tải từ một nguồn khác với nguồn của tài nguyên. Đây là một cơ chế quan trọng để chống lại những nguy hại có thể xảy ra do mã độc.

Trước khi quyết định về việc hai url có cùng một nguồn hay không, hãy xem xét về các thành của một url (Uniform Resource Locator).

Nói về url thì url có các thành phần cơ bản là: Giao thức://Tên domain (hoặc ip):cổng (nếu có chỉ định)/path

Hãy xem thử ví dụ dưới đây:

Đối với http://www.example.com/static giao thức chính là http, tên miền là www.example.com, path chính là static. Số cổng thì bị bỏ qua trong url này vì nó đang dùng cổng mặc định (80).

Điều cần chú ý ở đây là phần tên miền. example.com là tên miền chính và www.example.com là tên cấp hai. Và cả với a.example.com (cũng có thể là tên miền cấp hai). Cả ba tên miền trên là ba tên miền khác nhau.

Tôi sẽ không đi sâu vào chi tiết ở đây. Để biết thêm thông tin, bạn có thể tìm hiểu thêm các tài liệu về mạng máy tính.

Trở lại với câu hỏi cũ. Liệu có cách nào để nhận biết các trang có cùng tên miền không?

Câu trả lời là nếu giao thức và cổng (nếu được chỉ định) và hai tên miền đều giống nhau thì cả hai trang được xem là cùng một nguồn.

Hãy nói dễ hiểu hơn là ba yếu tố để xác định sự tương đồng chính là: giao thức, tên miền và cổng.

Cần lưu ý là nếu tên miền đó là tên miền cấp hai chẳng hạn như www.example.com được đề cập ở trên, đồng thời tên miền đó được liên kết với một tên miền cấp hai khác là a.example.com. Mặc dù có tên miền chính giống nhau, nhưng tên miền phụ khác nhau nên chúng không phải cùng tên miền. Do đó, kéo theo hai tên miền này không thể tính là tương đồng. Điều này cũng tương tự với tên miền cấp ba, v.v...

Bảng dưới đây cung cấp những ví dụ về việc đánh giá độ tương đồng của các url với http://store.company.com/dir/page.html

url Kết quả Lý do
http://store.company.com/dir2/other.html Tương đồng
http://store.company.com/dir/inner/another.html Tương đồng
https://store.company.com/secure Không tương đồng Khác giao thức (http và https)
http://store.company.com:81/dir/etc.html Không tương đồng Khác cổng (80 và 81)
http://news.company.com/dir/other.html Không tương đồng Khác tên miền (news và store)

Giới thiệu về Cookie và Session

Khi bàn về Same Origin Policy, có một số điều cần thiết phải làm. Đó là nói về Cookie

Khi nói về Cookie, chúng ta nên tìm hiểu thêm về Session

Đối với cả hai, đây không cần phải có một phần giới thiệu lớn.

Nên ở đây, chúng ta tóm tắt ngắn gọn nhau sau:

  • Cookie bị hạn chế bởi same-origin policy. Cookie ở trang A không thể được truy cập và chỉnh sửa khi bạn đang ở trang B.
  • Sức lưu trữ tối đa của Cookie thường phổ biến ở mức 4KB và được thiết lập bởi server. Có thể chỉ định nội dung và vòng đời của Cookie được tạo ra bằng cách thiết lập giá trị cho Set-Cookie trong HTTP headers. Nếu được tạo ra bởi trình duyệt, nó sẽ bị vô hiệu hóa sau khi trình duyệt đóng lại.
  • Cookie được lưu trữ ở trình duyệt, bởi vì mỗi lần một HTTP request được gửi, Cookie được gắn vào HTTP header một cách mặc định. Do điều đó nên Cookie chủ yếu được sử dụng nhầm xác thực thông tin. Đồng thời Cookie cũng không thường dùng để lưu trữ thông tin vì như vậy sẽ làm Cookie bị phình ra.
  • Session được lưu trữ trên server và thường được kết hợp sử dụng với Cookie nhằm thực hiện xác thực và duy trì trạng thái.

Một số ví dụ về Cookie và Session

Giả sử chúng ta có một hệ thống quản lý thông tin sinh viên. Tại một thời điểm mà cơ sở dữ liệu đã có đủ các thông tin liên quan đến sinh viên như tài khoản, mật khẩu, các thông tin cá nhân... Sau đó, sinh viên đăng nhập vào hệ thống bằng các gửi thông tin tài khoản qua mật khẩu tới server backend bằng phương thức POST. Khi đó server sẽ thực hiện kiểm tra các tham số gửi lên và kiểm tra xem nó có khớp với những gì được lưu trong cơ sở dữ liệu.

Nếu quá trình kiểm tra thành công, tức là thông tin gửi lên là chính xác. Server backend sẽ ghi lại giá trị trong Session. Thường là tên tài khoản hoặc một trường duy nhất nào có của người dùng (id chẳng hạn).

Sau khi lưu dữ liệu lại trong Session, server sẽ phản hồi về cho phía client rằng quá trình đăng nhập đã thành công và client có thể thực hiện các chức năng (đòi hỏi phải đăng nhập) tiếp theo. Đồng thời, cũng trong thời điểm này server backend sẽ thêm một thuộc tính là Set-Cookie trong HTTP response, giá trị của nó chính là SessionID của Session hiện tại (SessionID này đang trỏ đến Session hiện tại của chúng ta, trong Express thì express-session sẽ thực hiện thiết lập quá trình này). Tất nhiên quá trình này cũng bao gồm việc thiết lập một số thuộc tính khác của Cookie như Expire time...

Khi trình duyệt nhận được HTTP response, nó sẽ thiết lập một cookie local. Thời gian hết hạn chính được xác định bởi giá trị của trường Expires trong Set-Cookie thuộc response trả về. Nếu trình duyệt bị đóng (hoặc khi Session hết hạn) mọi thứ sẽ bị vô hiệu hóa.

Sau quá trình đăng nhập, mỗi request gửi đến server từ trình duyệt sẽ được thêm những thông tin vài cookie một cách mặc định. Theo cách thức này, mỗi khi nhận được request từ client thì server sẽ tìm kiếm Session của chúng ta dựa vào SessionID trong cookie.

Nếu SessionID gửi lên được tìm thấy thì có nghĩa là client đã đăng nhập và chúng ta có thể thực hiện các chức năng tiếp theo.

Có một điều đáng lưu ý là cơ chế xác minh Session này người dùng hiện tại chỉ có thể lấy được Session của họ và sẽ không thể lấy được những Session khác. Mỗi Session của mỗi người dùng cũng giữ sự độc lập với nhau. Đồng nghĩa với việc khi có nhiều người dùng hệ thống trong một thời gian thì hệ thống cũng sẽ lưu trữ nhiều Session tương ứng.

Trên đây, tôi đã trình bày về các ví dụ của Session và Cookie.

Giới thiệu về iframe

Khi nói về các hạn chế của Same Origin Policy thì chúng ta còn có thể đề cập đến iframe.

iframe dùng để nhúng một trang con trong một trang lớn. Trong quá trình phát triển sản phẩm hằng ngày chúng ta không thể tránh được những vấn đề khi giao tiếp giữa các iframe khác nhau trong trang. Như sử dụng DOM, function hoặc biến của các iframe khác.

iframe cũng chịu ảnh hưởng bởi Same Origin Policy. Thuộc tính src của iframe chính là url trong Same Origin Policy. Còn về việc tương tác với DOM, biến và function của iframe cũng không quá phức tạp, chúng ta có thể dùng contentDocumentcontentWindow

Nếu hai iframe không tương đồng thì khả năng truy cập sẽ bị giới hạn.

Để giải quyết vấn đề trên. HTML5 giới thiệu một API mới đó là postMessage, một phương thức chủ yếu để giải quyết vấn đề liên lạc giữa iframe và cross-domain.

Sau đâu là một ví dụ: Nếu có hai trang khác nhau. Url của trang A là http://localhost:4002/parent.html còn url của trang B là http://localhost:4003/child.html. Bây giờ, tôi sẽ nhúng trang B vào trang A dưới dạng iframe cùng với đoạn code được đặt như bên dưới... Bây giờ tôi muốn gửi một thông điệp vào trang con B.

Code ở trang A


<body>
<h1>A页面</h1>
<iframe src="http://localhost:4003/child.html" id="child">
</iframe>
<script>
    window.onload = function() {
        document.getElementById("child").contentWindow.postMessage("父页面发来贺电", "http://localhost:4003");
    } // 父页面发来贺电 => lời nhắn từ trang gốc
</script>
</body>

Code ở trang B


<body>
    <h1>B页面</h1>
    <script>
        window.onload = function() {
            window.addEventListener("message", function(e) {
                //Kiểm tra xem tin nhắn có phải đến trang gốc hay không để đảm bảo tính bảo mật
                if(e.source != window.parent) return;
                alert(e.data);
            });
        };
    </script>
</body>

Kết quả như sau:

postMessage nhận hai tham số đó là dữ liệu gửi đi và nguồn của iframe cần gửi (như ở đoạn code phía trên là http://localhost:4003). Trong trường hợp bạn muốn gửi cho tất cả các iframe bất kể nguồn nào bạn có đặt giá trị là "*". Về phần iframe muốn nhận được thông tin thì có thể sử dụng window.addEventListener ("message", function () {}).

Những trường hợp không bị giới hạn bởi Same Origin Policy

  • Thẻ script được phép nhúng các đoạn script từ cross-domain. JSONP được xem như một công nghệ dùng để khai thác "lỗ hổng" này.
  • Các thẻ img, link@font-face không bị ảnh hưởng bởi cross-domain
  • Phần tài nguyên trong các thẻ videoaudio cũng không bị giới hạn.
  • Bất cứ tài nguyên nào cũng iframe (Các iframe không giao tiếp được với nhau).
  • Plugins dành cho <object>, <embed>, và <applet>
  • WebSocket cũng không chịu sự giới hạn của Same Origin Policy

Giải pháp chung cho các developer

CORS - Chia sẽ tài nguyên của cross-domain

Lưu ý: Hai hướng phân tích sau đây về việc chia sẽ tài nguyên với cross-domain được trích xuất trong bài viết Chia sẽ cross-domain (跨域资源共享) của 阮一峰 (Nguyễn Nhất Phong)

Chia sẽ tài nguyên cross-domain là cái gì?

CORS là một tiêu chuẩn của W3C, tên gọi đầy đủ là Cross-origin resource sharing

CORS cho phép trình duyệt đưa ra các XMLHttpRequest tới các cross-origin server. Đồng thời request đó có thể vượt qua các giới hạn của AJAX khi chỉ có thể gửi request đến những nơi có cùng một nguồn.

Việc triển khai CORS chủ yếu dựa vào những cài đặt ở phía server. . Phần frontend thì gần như không thay đổi mấy so với việc sử dụng AJAX bình thường. Chỉ là chúng ta cần cập nhật một số cài đặt liên quan tới AJAX mà tôi sẽ nói trong phần tiếp theo.

Hai loại request của CORS

Để có thể chia sẽ được tài nguyên từ cross-domain, chúng ta cần xem xét hai loại request dưới đây.

Trình duyệt chia CORS ra làm hai loại request. Loại đầu tiên là loại đơn giản còn loại còn lại thì... chắc chắn là loại không đơn giản rồi. (Người dịch: vãi ông viết bài -_-)

Chỉ cần đạt được hai yêu cầu sau thì chúng ta có thể đánh giá đó là single request.

Phương thức yêu cầu là một trong ba phương thức sau đây:

  • HEAD
  • GET
  • POST

Thông tin của HTTP Header không vượt quá các trường sau đây:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type: Giới hạn trong ba giá trị sau đây application/x-www-form-urlencoded, multipart/formdatatext/plain

Còn nếu request không thõa mãn bất kỳ một điều kiện nào trong những điều kiện kể trên thì đó là preflight request

Trình duyệt cũng có cách xử lý khác nhau đối với các loại request.

Simple request

Đối với simple request thì trình duyệt sẽ gửi CORS request trực tiếp. Cụ thể, sẽ thêm trường Origin vào bên trong phần HTTP header như sau:


GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

Trường Origin ở phía trên được dùng để chỉ định nguồn (protocol + tên miền + cổng) mà request đến. Dựa trên giá trị này, server sẽ quyết định là có thông qua request hay không.

Nếu tên nguồn được khai báo trong Origin không nằm trong phạm vi những nguồn được cho phép của server thì server sẽ trả về một HTTP response bình thường. Khi nhận được HTTP response từ server và không hề chứ trường Access-Control-Allow-Origin (như bên dưới) thì có nghĩa đã có đó không đúng xảy ra. Và lỗi đó sẽ được tìm thấy ở function onerror của XMLHttpRequest. Lưu ý một tí là bạn không nên dùng status code để phát hiện lỗi này vì đôi lúc status code trả về sẽ là 200 (dù có phát sinh lỗi).

Còn trong trường hợp tên nguồn nằm trong Origin nằm trong phạm vi những nguồn được cho phép của server thì phần header được phản hồi lại sẽ có dạng tương tự như sau:


Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

Trong HTTP response header trên thì có ba trường có liên quan đến CORS được bắt đầu bằng Access-Control-

Access-Control-Allow-Origin

Đây là một trường bắt buộc, giá trị của trường này là những thứ mà Origin yêu cầu hoặc là "*" (cho biết rằng mọi tên miền đều được chấp nhận).

Access-Control-Allow-Credentials

Đây là một trường không hề bắt buộc. Kiểu giá trị của trường này là Boolean quy định rằng Cookie có cần được gửi hay không. Mặc định thì CORS không bật trường này lên. Còn nếu có giá trị là true thì có nghĩa server quy định rằng bạn phải gửi cookie lên kèm trong request. Giá trị của trường này luôn là true. Nếu không dùng thì bạn chỉ cần xóa nó đi (thay vì set lại là false).

Một điều lưu tâm là đển CORS có thể hỗ trợ Cookie thì bạn không chỉ phải thiết lập trong HTTP response từ server. Mà còn phải dùng withCredentials dành cho AJAX như sau (cấu hình dành cho jQuery sẽ được đề cập sau):


var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

Đôi khi một số trình duyệt vẫn gửi Cookie dù bạn đã bỏ qua thuộc tính withCredentials. Vì vậy, trong trường hợp đó, để tắt nó thì chúng ta có thể thao tác đơn giản như bên dưới:


xhr.withCredentials = false;

Chú ý rằng nếu bạn muốn gửi Cookie lên thì Access-Control-Allow-Origin không được phép set là * mà phải đặt một tên miền cụ thế. Nếu bạn đang thực hiện debug ở local thì bạn có thể xem xét để chuyển đổi cài đặt sang null.

Access-Control-Expose-Headers

Giống như Access-Control-Allow-Credentials, đây cũng không phải là một trường bắt buộc. Trong CORS, phương thức getResponseHeader() của XMLHttpRequest chi có thể lấy được sáu trường là: Cache-Control, Content-Language, Content-Type, Expires, Last-ModifiedPragma. Nếu bạn muốn lấy thêm những trường khác, bạn bắt buộc phải chỉ định chúng trong Access-Control-Expose-Headers. Kết hợp với ví dụ ở trên, bạn có thể dùng getResponseHeader('FooBar') để lấy dữ liệu của FooBar

Preflight request

Preflight request là loại request đặc biệt dành cho server. Ví dụ như sẽ dụng phương thức như PUT hoặc DELETE hay trường Content-Type có giá trị là application/json.

Một CORS request đối với preflight request sẽ được bổ sung thêm một HTTP request, được gọi là "pre flight (tiền trạm)" request sẽ được gửi đi. Sau đó, mới đến request chính thức.

Ở request đầu tiên từ trình duyệt, server sẽ kiểm tra xem tên miền hiện tại có nằm trong danh sách được phép hay không. XMLHttpRequest sẽ chỉ gửi đi yêu cầu chính thức nếu nhận được một response tích cực từ server. Nếu response trả về không được như mong đợi thì server cũng sẽ trả lỗi về.

Đây là đoạn code JavaScript


var url = 'http://api.alice.com/cors';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

Rõ ràng chúng ta có thể thấy rằng đây là một preflight request. Phương thức được gửi đi là PUT, đồng thời kèm theo một trường được custom trong HTTP header.

Do vậy, nên trình duyệt sẽ đánh giá đây là một preflight request, đồng thời sẽ tự động gửi ra một request "tiền trạm" đến server. Dưới đây là thông tin HTTP header request của preflight request này.


OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

Phương thức sử dụng trong request tiền trạm này là OPTIONS, để cho phía server biết được request này là request thăm dò. Trong thông tin của Header gồm có trường Origin cho biết nguồn của yêu cầu đến từ đâu.

Ngoài Origin ra thì phần header của request "tiền tạm" cũng có hai trường cần để ý.

Access-Control-Request-Method

Trường này là bắt buộc, nhằm để khai báo phương thức của CORS request. Như ở trên chúng ta có thể thấy là PUT

Access-Control-Request-Headers

Trường này là một chuỗi được phân cách bằng dấu phẩy chỉ định những thông tin mà trong header của CORS request sẽ gửi thêm (ngoài những thông tin mặc định). Như đã thất ở trên đó là X-Custom-Header.

Access-Control-Request-Headers

Sau khi nhận được request "tiền trạm" thì server sẽ kiểm tra các trường Origin, Access-Control-Request-MethodAccess-Control-Request-Headers. Nếu hợp lệ, server sẽ phản hồi lại cho trình duyệt như sau:


HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

Nếu server từ chối request tiền trạm này, server sẽ trả về một HTTP response bình thường và không có trường nào liên quan đến CORS. Đồng thời ngay lúc này, trình duyệt sẽ giả định rằng server không đồng ý với với yêu cầu tiền trạm. Vì thế nên XMLHttpRequest sẽ kích hoạt lỗi trong onerror dưới dạng như bên dưới:


XMLHttpRequest cannot load http://api.alice.com.
Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.

Ngoài ra còn một số trường khác có liên quan đến CORS mà server phản hồi:


Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000

So sánh với nhưng gì mà CORS trả về cho simple request thì ở đây chúng ta có thể thấy được ba trường khác biệt như sau:

Access-Control-Allow-Methods

Đây là một trường bắt buộc, giá trị của nó là một chuỗi được ngăn cách bằng dấu phẩy. Liệt tất cả những phương thức của cross-domain được hỗ trợ bởi server. Lưu ý rằng tất cả các phương thức mà server hổ trợ sẽ được trả về. Tôi lặp lại điều này để tránh việc nhầm lẫn với Access-Control-Allow-Methods của request tiền trạm mà tôi đã đề cập ở phía trên.

Access-Control-Allow-Headers

Nếu trình duyệt gửi request có chứa Access-Control-Allow-Headers, thì trường Access-Control-Allow-Headers sẽ là bắt buộc. Giá trị của trường này là mỗi chuỗi được phân cách bởi dấu phẩy, bao gồm các trường custom được server hỗ trợ. Giống như ở trên, những giá trị của Access-Control-Allow-Headers ở đây cũng không bị giới hạn bởi những giá của request tiền trạm ở trên.

Access-Control-Max-Age

Đây là một trường không bắt buộc, dùng để chỉ định thời hạn của request tiền trạm này và đơn vị sử dụng là giây. Trong đoạn kết quả ở trên chúng ta có thể thấy giá trị là 1728000 giây (20 ngày). Nghĩa là phản hồi này sẽ được cache lại trong 20 ngày. Trong thời gian này, sẽ không cần phải gửi yêu cầu tiền trạm nửa.

Do đó, mỗi khi mà trình duyệt vượt qua được "trạm soát vé" ở phía server, mỗi request CORS tiếp theo sẽ được xem như một simple request, vẫn có trường Origin. Phản hồi của server cũng sẽ có Access-Control-Allow-Origin. Nếu bạn có sử dụng Cookie thì Access-Control-Allow-Credentials cũng sẽ được thiết lập là true (như ở trên đã đền cập).

Làm thể nào để có thể dùng CORS cho cross-domain khi sử dụng Node?

Câu hỏi của phần này là cách thức để thực hiện chia sẽ tài nguyên cho cross-domain với Express.

Thực tế, cách thức khá đơn giản. Bạn chỉ cần phải thiết lập các trường đã được đề cập ở trên. Đồng thời cần để ý các vấn đề như hai loại yêu cầu, có sử dụng Cookie hay không. Bạn có thể tham khảo đoạn code dưới đây.


app.all("*", function(req, res, next) {
    res.header("Access-Control-Allow-Origin", /* url | * | null */);
    res.header("Access-Control-Allow-Headers", "Authorization, X-Requested-With");
    res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS"); /* Những phương thức mà server sẽ hỗ trợ */
    res.header("Access-Control-Allow-Credentials", "true"); /* Bật lên khi bạn sử dụng Cookie */
    res.header("Access-Control-Max-Age", 300000); /* Thời gian hiệu lực của yêu cầu tiền trạm */
    if (req.method === "OPTIONS") return res.send(200); /* Cho phép các request với phương thức OPTIONS phản hồi lại nhanh */
    else next();
});

Nhìn vào đoạn code trên, có một số phần tôi cần các bạn lưu ý:

  • Nếu bạn cần debug ở local, bạn cần đặt giá trị Access-Control-Allow-Origin thành null để có thể sử dụng Cookie. Vì mặc dù bạn vẫn có thể gửi các request nếu đặt giá trị là *, nhưng lúc này sẽ không thể nào dùng được Cookie.
  • Access-Control-Allow-Headers là không bắt buộc phải thiết lập, cho nên phải chỉ cần phải thiết lập nó khi nào những request được gửi lên có Access-Control-Allow-Headers
  • Hãy nhớ config Access-Control-Allow-Methods khi bạn muốn dùng Cookie.
  • Request tiền trạm có thể được được thiết lập cache. Điều đó sẽ đảm bảo cho bạn rằng không xảy ra hiện tượng nhiều request tiền trạm được gửi đi cùng một lúc.
  • Cần xác định request hiện tại có phải là request tiền trạm hay không. Nếu phải thì return ngay. (Người dịch: Các bạn có thể tham khảo đoạn if phía trên. Làm thế nhầm đảm bảo không xảy ra các thao tác thừa thãi. Vì đôi khi các thao tác thừa đó có thể gây ra các bug tiềm ẩn.).

Như đã từng nói ở trên. Trong trường hợp bạn dùng jQuery để gửi AJAX thì các bạn có thể tham khảo đoạn code dưới đây.


$.ajaxSetup({ xhrFields: { withCredentials: true }, crossDomain: true });

Bật withCredentials nếu bạn muốn gửi đi Cookie. Mặc định thì jQuery sẽ không gửi.
Bật crossDomain để AJAX có thể sử dụng cross-domain.

Như vậy, trên đây tôi đã giới thiệu sơ về CORS dành cho cross-domain. Sau đây, chung ta sẽ đến phần tiếp theo của bài viết.

Triển khai cross-domain với công nghệ JSONP

JSONP

Về JSONP, như tôi đã đề cập trước đó. Thực tế, việc dùng nó để giải quyết vấn đề Same Origin Policy được xem là lợi dụng một "lổ hỏng". Nhưng không sao, tất cả cũng là để hoàn thành đại cuộc "khúc tuyến cứu quốc" .

Như đã biết, thẻ script không bị giới hạn bởi Same Origin Policy. Và phương pháp này thì tận dụng điều đó bằng các thêm tự động các thẻ script vào trang. Đồng thời dùng các xử lý có sẵn (từ server) để lấy được dữ liệu.

Có một điều tôi muốn nhấn mạnh, đó là phương pháp này hoàn toàn khác với CORS. Đây cũng không phải là một đặc điểm kỹ thuật. Cái thứ mà chúng ta gọi là JSONP thật ra là một cách mới trong việc ứng dụng JSON. Hay nói đơn giản, nó chỉ là một đoạn JSON được thêm vào trang thông qua một function.

Hai thành phần của JSONP đó chính là dữ liệu và callback function. Trong đó, callback function sẽ được gọi khi trang hiện tại có phản hồi. Dữ liệu JSON sẽ được truyền vào bên trong callback function đó. Dưới đây, tôi sẽ mô phỏng quá trình đó của JSONP

Nguyễn tắc của JSONP có thể được tham khảo ở đây (tiếng Trung).

Mô phỏng ngắn gọn quá tình giao tiếp của JSONP


function handleResponse(response) {
    console.log(response.data);
}
var script = document.createElement("script");
script.src = "http://example.com/jsonp/getSomething?uid=123&callback=hadleResponse"
document.body.insertBefore(script, document.body.firstChild);
/*handleResponse({"data": "hey"})*/

Quy trình trong đoạn code trên được diễn ra như sau:

  • Khi chúng ta request một thẻ script mới, một dữ liệu JSON tương ứng sẽ được tạo ra (từ nguồn trong src mà chúng ta đã chỉ định). Ví dụ như ở trên, chúng ta có thể thấy handleResponse được thêm vào link với tên là callback. .
  • Sau đó, dữ liệu JSON được trả về (chúng ta có thể xem nó như một file js). Thứ mà sẽ là tham số được gọn trong function.
  • Do chúng ta đã khai báo function ở phía trên. Nên ngay khi dữ liệu được tải về (bên trong thẻ script). Function đó sẽ được gọi trực tiếp với dữ liệu mà chúng ta vừa thu được.
  • Tại thời điểm này, việc giao tiếp với cross-domain xem như đã được hoàn tất.

Ngoài ra, để có thể thực hiện JSONP thì chúng ta không thể thiếu đi sự cài đặt tương ứng ở phía server.

Đương nhiên, JSONP cũng có những hạn chế nhất định:

  • Chỉ có thể dùng phương thức GET
  • Có kha khá khả năng rủi ro về bảo mật trong quá trình gửi yêu cầu.
  • Sẽ không dễ để xác định một request JSONP bị thất bại.

Một cách triển khai JSONP đơn giản

Dưới đây là một thư viện dùng để triển khai JSONP. Hãy thử phân tích source code của nó. Đồng thời các bạn có thể tham khảo nó ở Github.


/**
 * Module dependencies
 */

var debug = require('debug')('jsonp');
// Gọi thư viện ra

/**
 * Module exports.
 */

module.exports = jsonp;
// exports jsonp

/**
 * Callback index.
 */

var count = 0;
// Biến này được xem như một index, dùng để generate ra id (không trùng lặp)。

/**
 * Noop function.
 */

function noop(){}
//đây là một function trống. Thuộc tính window[id] sẽ được gán vào function này sau.

/**
 * JSONP handler
 *
 * Options:
 *  - param {String} qs parameter (`callback`)
 *  - prefix {String} qs parameter (`__jp`)
 *  - name {String} qs parameter (`prefix` + incr)
 *  - timeout {Number} how long after a timeout error is emitted (`60000`)
 *
 * @param {String} url
 * @param {Object|Function} optional options / callback // callback function ở đây là function được gọi sau khi có dữ liệu, không phải là callback function gửi lên server
 * @param {Function} optional callback
 */


function jsonp(url, opts, fn){
  if ('function' == typeof opts) {
    fn = opts;
    opts = {};
  }
  if (!opts) opts = {};

  var prefix = opts.prefix || '__jp';

  // use the callback name that was passed if one was provided.
  // otherwise generate a unique name by incrementing our counter.
  var id = opts.name || (prefix + (count++));

  var param = opts.param || 'callback';
  var timeout = null != opts.timeout ? opts.timeout : 60000;
  var enc = encodeURIComponent;
  var target = document.getElementsByTagName('script')[0] || document.head;
  var script;
  var timer;

  //Server sẽ không trả dữ liệu về nếu hết thời gian chờ.
  if (timeout) {
    timer = setTimeout(function(){
      cleanup();
      if (fn) fn(new Error('Timeout'));
    }, timeout);
  }
  // Dọn dẹn, trở về trạng thái ban đầu (Người dịch: sử dụng phương thức noop như mình đã đề cập phía trên.)
  function cleanup(){
    if (script.parentNode) script.parentNode.removeChild(script);
    window[id] = noop;
    if (timer) clearTimeout(timer);
  }
  // Hủy hoạt động
  function cancel(){
    if (window[id]) {
      cleanup();
    }
  }
    
  // Khai báo function mà sau khi url được tải vào trong thẻ script sẽ được gọi
  window[id] = function(data){
    debug('jsonp got', data);
    cleanup();
    if (fn) fn(null, data);// tham số đầu tiên trong node là err, nếu không thể truyền vào ở đây thì null sẽ được truyền vào.
  };

  // add qs component
  url += (~url.indexOf('?') ? '&' : '?') + param + '=' + enc(id);
  url = url.replace('?&', '?');

  debug('jsonp req "%s"', url);

  // create script
  script = document.createElement('script');
  script.src = url;
  target.parentNode.insertBefore(script, target);
  // sau khi được khởi tạo, function được khai báo sẽ được gọi trực tiếp với những dữ liệu được lấy từ thẻ script

  return cancel;
  // quay lại trạng thái ban đầu.
}

Đóng gói thư viện JSONP

Sử dụng thư viện trên cho quá trình đóng gói, dưới đây sẽ là function _jsonp của chúng ta:


/* Đây là một function _jsonp được định nghĩa bởi chính tôi */
/**
 * @param {String} url 
 * @param {Object} data 
 * @param {Object} option 
 * @returns 
 */
function _jsonp(url, data, option) {
  url += (url.indexOf('?') < 0 ? '?' : '&') + param(data);

  return new Promise((resolve, reject) => {
    jsonp(url, option, (err, data) => {
      if (!err) {
        resolve(data);
      } else {
        reject(err);
      }
    });
  });
  /* jsonp sẽ được trả về dưới dạng Promise. Nếu mọi chuyện ổn thì dữ liệu sẽ được đưa vào resolve */
}
// Tiếp theo là một function để xử lý các tham số
function param(data) {
  let url = '';
  for (var k in data) {
    let value = data[k] !== undefined ? data[k] : '';
    url += `&${k}=${encodeURIComponent(value)}`;
  }
  return url ? url.substring(1) : '';/*  những substring dưới đây sẽ đảm bảo không có thêm & */
}

Sử dụng JSONP với jQuery

Ngoài ra, chúng ta có thể sử dụng JSONP với jQuery. Đây là một đoạn code mẫu ngắn gọn về cách dùng.


$.ajax({  
    type: "get",  
    url: "http://example.com",  
    dataType: "jsonp",  
    jsonp: "callback",
    jsonpCallback: "responseCallback",
    success: function (data) {  
        console.log(data);
    },  
    error: function (data) {  
        console.log(data);
    }  
});

Trong AJAX, thiết lập dataType là jsonp. Còn đối với jsonp thì có mặc định là callback. Còn với jsonpCallback thì giá trị được jQuery tạo mặc định. Nếu bạn muốn tự định nghĩa function lại, các bạn có thể thiết lập lại jsonpCallback như trong đoạn code trên. Trong đoạn code trên, đoạn url cuối cùng được gửi sẽ là http://example.com?callback=responseCallback

Sử dụng proxy server để chuyển tiếp các request

Bởi vì Same Origin Policy chỉ tồn tại trong trình duyệt. Nên trong giao tiếp giữa server và server, thì không bị giới hạn bởi sự tương đồng.
Do đó, việc sử dụng proxy server như là một giải pháp trong việc giải quyết các vấn đề của cross-domain, cũng là một cách phổ biển trong quá trình phát triển phần mềm hàng ngày của chúng tôi.

Cách triển khai cũng rất đơn giản, chỉ cần NodeExpress.

Bạn cũng nên lưu ý rằng thường thì server sẽ có cơ chế xác minh riêng. Ví dụ: các bài viết của WeChat sẽ không thể tải được trong iframe, bởi vì server được xác minh bởi referer. Ngoài ra, đối với một số server còn đòi hỏi cung cấp xác minh như phải gửi một chuỗi uid... Do đó, khi dùng proxy server, chúng ta nên tập trung vào các tham số của request để có thể mô phỏng chính xác nhất request cần mô phần, góp phần đảm bảo quá trình chuyển tiếp chính xác nhất.

Điểm qua một các tổng quan về cách mà proxy server thực hiện chuyển tiếp request:

  • Phân tích các tham số cần thiết của request url.
  • Proxy server sẽ gọi api. Chức năng thực tế của giai đoạn này request đến server thật.
  • Sau đó, proxy server mà bạn đã tạo sẽ giúp bạn chuyển tiếp request mà bạn muốn đến server thật sự với các tham số cần thiết.

Cuối cùng, tôi sẽ thực hiện một ví dụ về sử dụng proxy server nhằm vượt qua phần bảo mật của WeChat.

Vậy làm cách nào để vượt qua cơ chế chống trộm của WeChat?

Giả sử chúng ta có một trang website, thẻ img sẽ tham chiếu đến một hình ảnh của WeChat. Hãy xem điều gì xảy ra sau đây.

Đây là cái tôi gọi là cơ chế chống trộm.

Bây giờ, hãy thử tạo một proxy server. Code của nó như sau:


var express = require("express");
var superagent = require("superagent");
var app = express();

app.use("/static", express.static("public"));

app.get("/getwxImg", (req, res) => {
    // xử lý không bị mất url vì có hai dấu chấm hỏi
    var url = req.url.substring(req.url.indexOf("param=") + 6);
    res.writeHead(200, {
        'Content-Type': 'image/*'
    });
    superagent.get(url)
        .set('Referer', '')
        .set("User-Agent",
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36'
        )
        .end(function (err, result) {
            if (err) {
                return false;
            }
            res.end(result.body);
            return;
        });
});

app.listen(4001, (err) => {
    if (err) {
        console.log(err);
    } else {
        console.log("server run!");
    }
});

Bằng cách này, chúng ta có thể thay thế url đến server thực, bằng cách gọi đến proxy server và sử dụng url đó như là một tham số.


<!-- sử dụng proxy server -->
<img src="http://localhost:4001/getwxImg?param=http://mmbiz.qpic.cn/mmbiz/CoJreiaicGKekEsuheJJ7Xh53AFe1BJKibyaQzsFiaxfHHdYibsHzfnicbcsj6yBmtYoJXxia9tFufsPxyn48UxiaccaAA/640?wx_fmt=jpeg&wxfrom=5&wx_lazy=1&tp=webp">
<!-- dùng cách thông thường -->
<img src="http://mmbiz.qpic.cn/mmbiz/CoJreiaicGKekEsuheJJ7Xh53AFe1BJKibyaQzsFiaxfHHdYibsHzfnicbcsj6yBmtYoJXxia9tFufsPxyn48UxiaccaAA/640?wx_fmt=jpeg&wxfrom=5&wx_lazy=1&tp=webp">

Kết quả như sau:

Đúng là một kết quả hiển nhiên đúng không nào? Đây chính là một ví dụ về proxy server. Các bạn có thể xem toàn bộ code ở đây

Một số nguồn tham khảo:

Same Origin Policy: https://developer.mozilla.org/zh-CN/docs/Web/Security/Same-origin_policy

Cookie và Session: http://www.cnblogs.com/linguoguo/p/5106618.html

CORS: http://www.ruanyifeng.com/blog/2016/04/cors.html

Kết (người dịch)

Trên đây lại là một bài dịch mà mình đã tìm thấy trên segmentfault. Như các bạn cũng có thể thấy, đây là một bài viết hết sức chi tiết về vấn đề hạn chế tài nguyên giữa các tên miền khác nhau. Nhờ bài viết này mà mình cũng sáng ra khá nhiều thứ mà đó giờ mình không biết. Hy vọng bài viết này có ích cho các bạn đang làm việc với cross-domain. Cuối cùng, một lần nửa xin cám ơn tác giả về bài viết này.

Chú thích

  • Khúc tuyến cứu quốc:: Nguyên gốc hán tự là 曲线救国. Đây là một từ xuất hiện trong thời kháng Nhật. Chúng ta cũng có thể đọc là "gián tiếp cứu nước". Tronng giai đoạn này, người Trung Quốc làm đủ cách để làm chậm quá trình xâm chiếm của Nhật từ đánh du kích, biểu tình... Hy sinh việc nhỏ vì việc lớn. Câu này dùng trong bài với hàm ý là vì việc lớn mà bỏ qua tiểu tiết (vì muốn giải quyết vấn đề cross-domain mà dùng JSONP. Một cách mà tác giả xem đó là một "lỗ hỏng") []