Odoo lưu gì trong một session, các file nằm ở đâu trên đĩa, session token ràng buộc một session với mật khẩu người dùng ra sao, và vì sao một session đang dùng không bao giờ hết hạn.
Khi mở DevTools trong Odoo, đã bao giờ bạn tự hỏi cookie session_id che giấu điều gì chưa? Session là bộ xương vô hình của mọi tương tác đã xác thực trong Odoo. Hiểu chúng giúp debug nhanh hơn, lập luận về bảo mật rõ ràng hơn và vận hành production bớt mù mờ.
Một lưu ý về phạm vi ngay từ đầu: bài này nói về session HTTP, cơ chế theo dõi các kết nối trình duyệt đã xác thực. Session POS là khái niệm khác, gắn với module Point of Sale, và không nằm trong phạm vi bài viết.
Đây là bài đầu trong loạt ba bài. Bài 1 (bài này) nói về cách session được cài đặt hiện nay trong Odoo 19.0. Bài 2 nói về các module cộng đồng mở rộng hoặc thay thế nó. Bài 3 là phần hỏi đáp cho những câu hỏi về session chúng tôi nghe nhiều nhất trong thực tế.
Session là gì?
Nhìn từ phía người dùng
Một ứng dụng web được dựng trên các request HTTP riêng lẻ. Mỗi request là phi trạng thái: máy chủ nhận nó, trả về phản hồi, rồi quên cuộc trao đổi đó đi. “Session” là cơ chế gom một chuỗi request về dưới cùng một danh tính đã xác thực. Nó bắt đầu khi người dùng đăng nhập và kết thúc khi họ đăng xuất hoặc khi session hết hạn.
Điều này quan trọng khi debug. Khi một người dùng báo lỗi, đơn vị đáng quan tâm thường không phải một request đơn lẻ mà là cả một chuỗi: những request nào đã đi trước lỗi, chúng chạy trên trạng thái session nào, và liệu có request chạy song song nào đã làm thay đổi trạng thái đó không.
Nhìn từ phía máy chủ
Khi người dùng đăng nhập, Odoo tạo một session và gán cho nó một định danh duy nhất: session_id. Máy chủ trả định danh này về cho trình duyệt qua header phản hồi Set-Cookie:
Set-Cookie: session_id=bbe0bb65...; Expires=...; Max-Age=604800; HttpOnly; Path=/
Max-Age 604800 giây là bảy ngày, thời hạn session mặc định từ Odoo 16.0 (trước đó là 90 ngày). Trình duyệt lưu cookie và gắn nó vào mọi request sau đó:
Cookie: tz=Asia/Saigon; session_id=bbe0bb65...
Máy chủ đọc cookie, tra ra session tương ứng và dựng lại trạng thái của người dùng. Cờ HttpOnly là có chủ đích: nó chặn JavaScript đọc cookie, qua đó đóng cả một nhóm cách đánh cắp session dựa trên XSS.
Odoo đặt những gì vào một session?
Lớp Session
Session là một lớp con của collections.abc.MutableMapping: một đối tượng giống dict, có sẵn cơ chế theo dõi thay đổi [10]. Mỗi lần ghi vào một trường của session sẽ bật cờ nội bộ is_dirty, và vòng đời request dựa vào cờ này để quyết định có lưu file session khi kết thúc request hay không. Phần sổ sách nội bộ nằm trong __slots__, tách bạch hẳn với dữ liệu được lưu xuống đĩa:
class Session(collections.abc.MutableMapping):
__slots__ = ('can_save', '_Session__data', 'is_dirty', 'is_new', 'should_rotate', 'sid')
Các slot (is_dirty, should_rotate, can_save, is_new, sid) không bao giờ được ghi xuống đĩa. Chỉ dữ liệu dạng dict mới được tuần tự hóa.
Các trường của session
Bộ trường chuẩn của một session mới được định nghĩa trong get_default_session() [1]:
uid: ID người dùng đã xác thực (số nguyên), hoặcNonevới session ẩn danh. Đây là khóa ràng buộc một session với một người dùng.db: tên database. Odoo là hệ thống đa khách thuê; mỗi session gắn với đúng một database.login: chuỗi login của người dùng (thường là địa chỉ email). Dùng để hiển thị và làm đầu vào khi tính session token.context: dict tùy chọn của người dùng, chứa ngôn ngữ, múi giờ và các thiết lập khác. Nó được chuyển kèm trong mọi lời gọi RPC, nên đổi ngôn ngữ của người dùng ở đây có hiệu lực ngay mà không cần đăng nhập lại.session_token: một giá trị HMAC-SHA256 ràng buộc session với trạng thái hiện tại của người dùng. Nói kỹ ở mục 4.debug: các cờ chế độ debug (chuỗi). Quyết định thanh công cụ debug và phần ghi log bổ sung có bật hay không.create_time: dấu thời gian dạng số thực ghi lại lúc session được tạo. Dùng để kích hoạt soft rotation mỗi ba giờ._trace: dữ liệu nhật ký thiết bị phục vụ tính năng “Connected devices”, ghi lại các địa chỉ IP và trình duyệt đã truy cập session [8].
Odoo lưu session ở đâu?
Lưu trên file
Session của Odoo là các file JSON trên đĩa. Chúng không phải bản ghi trong database, cũng không phải trạng thái trong bộ nhớ. Vị trí mặc định là {data-dir}/sessions/, với data-dir đặt qua --data-dir lúc khởi động. Mỗi file được đặt tên theo session ID.
Tích hợp Werkzeug (vendor sẵn)
Odoo dựng session store của mình trên FilesystemSessionStore của Werkzeug, vốn nằm trong werkzeug.contrib.sessions. Werkzeug đã bỏ gói contrib từ phiên bản 1.0. Thay vì viết lại store, Odoo đưa hẳn module đó vào mã nguồn tại odoo/tools/_vendor/sessions.py [12] rồi kế thừa để thêm phần rải thư mục.
Rải ra 4096 thư mục con
Một instance Odoo bận rộn có thể tích tụ hàng chục nghìn file session. Để tất cả trong một thư mục sẽ làm giảm hiệu năng filesystem trên hầu hết hệ thống. Odoo rải file ra 4096 thư mục con: tên thư mục là hai ký tự đầu của session ID. Với 64 giá trị khả dĩ cho mỗi vị trí, ta có 64x64 = 4096 thư mục [2]:
def get_session_filename(self, sid):
sha_dir = sid[:2]
dirname = os.path.join(self.path, sha_dir)
return os.path.join(dirname, sid)
Một session có ID bbe0bb65... được lưu tại sessions/bb/bbe0bb65....
Định dạng session ID
Session ID là chuỗi base64url dài 84 ký tự, sinh ra bằng cách lấy 63 byte đầu của băm SHA-512 trên dấu thời gian hiện tại nối với 64 byte ngẫu nhiên từ hệ điều hành, rồi mã hóa base64url không padding [3]:
def generate_key(self, salt=None):
key = str(time.time()).encode() + os.urandom(64)
hash_key = sha512(key).digest()[:-1]
return base64.urlsafe_b64encode(hash_key).decode('utf-8')
Cách này cho khoảng 217 bit entropy mỗi session ID. Bảng ký tự an toàn với URL nghĩa là ID dùng thẳng được vừa làm giá trị cookie vừa làm tên file, không cần escape. 42 byte đầu của ID (giá trị của STORED_SESSION_BYTES) có vai trò đặc biệt: chúng được giữ nguyên qua soft rotation, điều này quan trọng với hiệu lực của token CSRF (mục 4 và mục 5).
Odoo bảo vệ session bằng cách nào?
Vấn đề: chỉ một cookie là chưa đủ
Nếu kẻ tấn công chặn được cookie session_id, chúng có thể phát lại nó. Một máy chủ chỉ tin vào giá trị cookie sẽ không phân biệt được request đó với request của người dùng hợp lệ. Odoo bổ sung một lớp xác minh thứ hai không truyền qua đường truyền.
Session token
Mỗi session lưu một session_token: giá trị HMAC-SHA256 tính từ session ID, dùng khóa là trạng thái riêng của người dùng — database.secret, login, mật khẩu đã băm và cờ active [5]. Ở mỗi request đã xác thực, Odoo tính lại token kỳ vọng từ trạng thái sống trong database rồi so với giá trị đã lưu bằng phép so sánh thời gian hằng để chặn tấn công đo thời gian [8].
Hệ quả rất đáng kể: đổi mật khẩu, vô hiệu hóa tài khoản hay đổi database.secret sẽ lập tức vô hiệu hóa toàn bộ session của người dùng đó. Kẻ tấn công đang giữ một cookie session_id hợp lệ cũng chẳng được gì nếu token tính lại không còn khớp. Các định dạng token cũ từ những phiên bản Odoo trước được tự động nâng cấp ngay ở lần kiểm tra thành công đầu tiên, không cần bước migration nào.
Token CSRF
Token CSRF là cơ chế riêng, chống giả mạo request khác site (một kiểu tấn công khác với chiếm session). Odoo sinh chúng dưới dạng HMAC-SHA1 với khóa database.secret, trên một thông điệp gồm 42 byte đầu của session ID cộng với một dấu thời gian [6]:
msg = f"{self.session.sid[:STORED_SESSION_BYTES]}{max_ts}".encode()
hm = hmac.new(secret.encode('ascii'), msg, hashlib.sha1).hexdigest()
Token mang sẵn thời hạn của chính nó: max_ts = now + CSRF_TOKEN_SALT (một năm). Dấu thời gian được đưa vào chủ yếu để giảm nhẹ rủi ro BREACH: vì max_ts đổi ở mỗi lần gọi, mỗi token sinh ra đều trông khác nhau ngay trong cùng một session, khiến các tấn công kiểu compression oracle khó hơn. Trên thực tế, token CSRF không sống lâu. Chúng gắn với 42 byte đầu của session ID, nên bị vô hiệu ngay khi đăng xuất (hard rotation thay luôn các byte đó). Trần một năm trên thực tế bị chặn bởi chính thời hạn không hoạt động bảy ngày của session.
Token CSRF sống sót qua soft rotation vì soft rotation giữ nguyên 42 byte đầu của session ID. Đó chính là lý do phần đầu này tồn tại.
Vai trò của database.secret
database.secret là khóa mật mã gốc cho cả session token lẫn token CSRF. Nó được lưu trong ir.config_parameter và trên thực tế hiếm khi bị đổi. Đổi nó sẽ vô hiệu hóa ngay toàn bộ session của mọi người dùng trên cả database: hữu ích như một biện pháp ứng cứu khẩn cấp khi nghi ngờ bị xâm nhập, nhưng gây gián đoạn trong vận hành bình thường.
Vòng đời của session
Sinh ra: session ẩn danh
Mọi request đầu tiên tới Odoo, kể cả trước khi đăng nhập, đều nhận được một session. Session đó có uid=None và db=None. File session được tạo theo kiểu lười: chỉ khi session trở nên dirty trong request thì nó mới được ghi xuống đĩa.
Đăng nhập: hard rotation
Khi xác thực thành công, Odoo thực hiện hard rotation: sinh một session ID mới, còn session cũ được xếp lịch xóa sau 120 giây ân hạn (để không làm rơi các request song song vẫn đang dùng ID cũ). Session mới nhận uid, db, login, context và một session_token vừa tính lại. Trình duyệt nhận cookie session_id đã cập nhật.
Session đang hoạt động: soft rotation mỗi ba giờ
Cứ sau ba giờ hoạt động, Odoo thực hiện một lần soft rotation [7]. 42 byte đầu của session ID được giữ nguyên; 42 byte cuối được sinh lại (ID đầy đủ dài 84 ký tự). Session cũ lưu một con trỏ next_sid để mọi request song song đến với ID cũ đều được chuyển hướng trong suốt:
if soft:
static = session.sid[:STORED_SESSION_BYTES]
next_sid = static + self.generate_key()[STORED_SESSION_BYTES:]
session['next_sid'] = next_sid
session.sid = next_sid
Cách này làm mới session ID vì lý do bảo mật mà vẫn giữ token CSRF hợp lệ suốt phiên làm việc của người dùng.
Đăng xuất: lại hard rotation
Đăng xuất kích hoạt một lần hard rotation nữa: session ID bị thay hoàn toàn, session cũ được xếp lịch xóa, và trình duyệt nhận một cookie session mới, rỗng.
Vì sao một session đang hoạt động thực chất không bao giờ hết hạn
Max-Age của cookie là một chỉ thị dùng một lần gửi cho trình duyệt: hãy xóa cái này sau N giây trừ khi có lệnh khác. Trình duyệt không hỏi lại máy chủ xem cookie còn dùng được không; nó chỉ đếm ngược tại chỗ. Vậy nên để một session sống quá Max-Age, máy chủ phải liên tục cấp lại cookie với đồng hồ đếm ngược mới trước khi cái cũ chạy hết.
Việc cấp lại đó xảy ra ở cuối mỗi request, nhưng chỉ khi session là dirty hoặc ID của nó đã đổi [9]:
if sess.is_dirty or cookie_sid != sess.sid:
self.future_response.set_cookie('session_id', sess.sid,
max_age=get_session_max_inactivity(env), httponly=True)
is_dirty không bật ở mọi request; nó chỉ bật khi có thứ gì đó ghi vào dữ liệu session. Với một request đã xác thực, lần ghi đó được bảo đảm ít nhất mỗi giờ một lần bởi check_session() [8], hàm này chạm vào mục vết thiết bị của session (_trace) khi lần cập nhật gần nhất trên thiết bị đó đã quá một giờ. Chỉ một lần ghi ấy là đủ để đánh dấu session dirty, kích hoạt lưu file và cấp lại cookie với Max-Age mới nguyên.
Cùng cơ chế đó bảo vệ file trên đĩa: vacuum() thu dọn session dựa trên thời điểm sửa file, và mtime đó chỉ tiến lên khi session được lưu — chính lần ghi hằng giờ kia giữ cho nó luôn mới.
Hệ quả thực tế: “session hết hạn sau bảy ngày không hoạt động” không có nghĩa là một cookie session đặt một lần sẽ đếm về 0 sau một tuần sử dụng liên tục. Nó có nghĩa là một cookie ngắn hạn (bảy ngày) được lặng lẽ cấp lại khoảng mỗi giờ chừng nào người dùng còn hoạt động, nên đồng hồ không bao giờ chạy tới 0. Chỉ khi các request thật sự dừng hẳn thì cookie được cấp lần cuối mới hết giờ, và mtime của file mới thôi tiến lên.
Hết hạn
Mặc định session hết hạn sau bảy ngày không hoạt động, do tham số hệ thống sessions.max_inactivity_seconds điều khiển. Một giá trị duy nhất này chi phối cả Max-Age của cookie gửi cho trình duyệt lẫn ngưỡng thu dọn phía máy chủ [13]. Sự nhất quán này có từ Odoo 16.0: trước đó, cookie có hiệu lực 90 ngày trong khi phần dọn rác phía máy chủ lại thu dọn session sau 7 ngày không hoạt động, nên thời hạn cookie trên thực tế chẳng có ý nghĩa gì.
Một điểm tinh tế khi vận hành: file session hết hạn không bị xóa ngay tại thời điểm hết hạn. Việc thu dọn diễn ra khi cron “Base: Auto-vacuum internal data” chạy _gc_sessions(), mặc định mỗi ngày một lần [14]. Nếu đặt sessions.max_inactivity_seconds ngắn, hãy tăng tần suất cron đó cho tương xứng. Lưu ý Odoo không kiểm tra tuổi session ở thời điểm request: trình duyệt bỏ cookie khi Max-Age trôi qua, nhưng một client không phải trình duyệt mà vẫn giữ cookie thì có thể dùng tiếp session cho tới khi cron thật sự xóa file. Mốc cắt thực tế là lúc thu dọn, không phải lúc chạm ngưỡng.
Khi request kết thúc, _save_session() đi qua một chuỗi quyết định [9]: hard rotate nếu should_rotate được bật, soft rotate nếu đã trôi qua ba giờ, ghi xuống đĩa nếu is_dirty, hoặc không làm gì cả.
Những điểm chính
Session của Odoo là các file JSON trên đĩa, mỗi file được định danh bằng một chuỗi base64url dài 84 ký tự. Trường session_token bên trong mỗi file là một giá trị HMAC-SHA256 ràng buộc session với trạng thái xác thực hiện tại của người dùng. Đổi mật khẩu hay vô hiệu hóa tài khoản sẽ vô hiệu hóa ngay toàn bộ session của người dùng đó, không cần xóa cache hay thao tác tay nào. Soft rotation mỗi ba giờ làm mới session ID mà không làm hỏng các token CSRF đang dùng dở.
Nắm được mô hình này, bạn có thể mở bất kỳ file session nào trong {data-dir}/sessions/, đọc từng khóa và giải thích chính xác nó ở đó để làm gì.
Nguồn
[1] odoo/http.py:234-245: get_default_session()
[2] odoo/http.py:968-1006: FilesystemSessionStore (rải thư mục con)
[3] odoo/http.py:1050-1070: generate_key() (base64url 84 ký tự, ~217 bit entropy)
[4] odoo/http.py:1459, 1786-1824: _get_session_and_dbname(), đọc cookie
[5] odoo/addons/base/models/res_users.py:851-884: _compute_session_token(), HMAC-SHA256
[6] odoo/http.py:1907-1956: csrf_token(), validate_csrf()
[7] odoo/http.py:1008-1040: rotate() (soft/hard rotation)
[8] odoo/service/security.py:13-33: check_session(), theo dõi thiết bị, nâng cấp token cũ
[9] odoo/http.py:2133-2168: _save_session(), đặt cookie
[10] odoo/http.py:1110-1142: định nghĩa lớp Session
[11] odoo/addons/base/models/res_users.py:832-856: _get_session_token_query_params()
[12] odoo/tools/_vendor/sessions.py: bản vendor của Werkzeug contrib.sessions
[13] odoo/http.py:452-461: get_session_max_inactivity() (đọc sessions.max_inactivity_seconds)
[14] odoo/addons/base/models/ir_http.py:406-410: _gc_sessions() (cron autovacuum)