先说结论
很多人看到 Odoo Portal 链接里的 access_token,第一反应是:
- 这不就是“免登录参数”吗?
这个理解只对了一半。
从 /home/ubuntu/odoo-temp/addons/portal/models/portal_mixin.py 和 controllers/portal.py 看,Odoo 真正做的不是“把登录去掉”,而是:
把某一条具体业务记录,包装成一个带对象级访问凭证的分享入口。
所以 Portal 的核心安全问题从来不是“用户有没有后台登录”,而是:
- token 是不是只绑定当前对象;
- 分享链接是否只放开当前记录必需的能力;
- 跳页、附件、评论等后续动作有没有继续守住同一边界。
portal.mixin 说明 token 是“记录级”而不是“用户级”
portal.mixin 上有两个关键字段:
access_urlaccess_token
其中 _portal_ensure_token() 的实现非常直白:
- 如果当前记录还没有 token,就写入一个 UUID;
- 返回这条记录自己的
access_token。
这透露出一个重要事实:
access token 的宿主是记录本身
也就是说,它不是一个“用户登录票据”,而是:
- 这张报价单自己的 token;
- 这张发票自己的 token;
- 这条项目分享记录自己的 token。
这和常见的 session cookie、JWT、OAuth access token 完全不是同一层东西。
它更像:
某个对象对外暴露的受控分享钥匙。
所以一旦你把它误当成“简化登录”的工具,就很容易在定制里把边界做大。
_get_share_url() 暴露了分享链接的真实组成
_get_share_url() 非常值得细读。
它支持:
redirect=False:直接去对象自己的access_urlredirect=True:先跳/mail/viewpid+hash:为某个 partner 生成额外认证参数signup_partner=True:允许被邀请人更容易建账号share_token=True:拼上access_token
这说明分享链接不是“一个 URL + 一个 token”这么简单,而是一个分层入口。
第一层:到哪条记录
由:
self.access_url- 或
model + res_id
决定。
第二层:凭什么看
由:
access_token- 或 partner 级
hash
决定。
第三层:是否顺带触发开户 / 门户身份承接
由:
signup_partner
这类参数决定。
也就是说,Portal 分享链接的安全性,不在于“有没有参数”,而在于每个参数都只放开自己那一层语义。
为什么 redirect=True 要走 /mail/view
很多人会忽略 _get_share_url() 里 redirect=True 的设计。
此时它不会直接返回 access_url,而是返回:
/mail/view?model=...&res_id=...&access_token=...
这背后反映的是一个很成熟的边界思路:
即使是分享访问,也最好通过一个显式的“受控入口”来重新校验对象与 token 是否匹配。
/mail/view 这种路由不是多此一举,它像一个前置闸门:
- 先验证你访问的是哪条记录;
- 再看 token 是否足够;
- 再决定该引导你进门户页、邮件线程页还是其他具体页面。
如果开发者在自定义里图省事,直接把后台记录 URL 拼出去,反而最容易把边界做散。
为什么 _get_share_url() 里会先 check_access('read')
源码里有个特别关键的小动作:
- 在生成
access_token之前,先self.check_access('read')
这说明 Odoo 并不是让任何能拿到 recordset 的代码都能随意产出分享链接。
这一步的真实含义是:
- 你先得对这条记录本来就有读取权;
- 然后系统才允许你替这条记录生成对外访问入口。
也就是说,分享链接生成本身就是受权限约束的动作。
这点非常重要,因为很多定制项目喜欢写一个通用按钮:
- 点一下就吐一个外链。
如果这一步不先守住原始 read 权限,最后就很可能变成“低权限用户替高价值对象批量生成分享链接”。
get_portal_url() 为什么把 token 继续带到 suffix / download / report
get_portal_url() 会在 URL 上继续追加:
access_tokenreport_typedownload=true- 额外 query string
- anchor
这一步经常被当成“方便拼参数”,但它的真正意义是:
对象级访问边界必须能沿着后续动作延续,而不是只在第一跳生效。
例如:
- 你先打开门户页;
- 接着点下载 PDF;
- 再点另一个报告格式;
- 再翻到带锚点的局部区域;
如果只有第一页验证 token,后续派生 URL 不带 token,访问体验会断; 如果后续派生 URL 完全不再校验 token,安全边界又会散掉。
所以标准实现选择的是:
- 在对象级 URL 家族里,把同一条记录的 token 继续传递下去。
分页与相邻记录为什么也要续传 token
controllers/portal.py 里的 get_records_pager() 同样很有代表性。
当当前对象有 access_url 时,它生成上一条 / 下一条链接,会自动拼:
?access_token=...
这说明官方意识到一个特别容易被忽略的问题:
浏览动作也会把访问边界带散
如果当前页有 token,但“上一条 / 下一条”不带 token,会出现:
- 当前页能看;
- 点相邻记录立刻 403;
- 或者开发者为了省事,把相邻页做成更宽松的公开查询。
标准实现没有这么做,而是明确要求:
- 你在浏览哪条对象,就为哪条对象续传其 own token。
这再次证明 portal token 是对象级的,不是一次登录后通吃整个列表的会话钥匙。
分享 token 与 partner hash 不是一回事
_get_share_url() 里如果传了 pid,还会生成:
pidhash = self._sign_token(pid)
这说明 Odoo 区分了两类凭证:
对象级 token
证明:
- 你拿着这条记录的分享钥匙。
partner 级 hash
证明:
- 这个链接是朝某个具体 partner 语义发出去的;
- 某些 portal chatter 或邀请链路可以据此认定更细的身份上下文。
如果把这两层混在一起,就会在定制里犯两个典型错误:
- 以为有
access_token就等于知道来访者是谁; - 以为有 partner hash 就能对整条记录无限授权。
其实它们分别回答的是不同问题:
- 能不能进这个对象?
- 来访者更像是哪一类被邀请对象?
Portal 真正危险的不是免登录,而是能力扩张
从源码看,官方设计非常克制:
- token 是记录级;
- 分享链接生成要过原始
read检查; - 跳转与后续派生 URL 都尽量围绕当前对象;
- partner 级身份补充与对象级 token 分开处理。
所以大多数安全事故并不是标准模块“天生不安全”,而是定制把能力放大了,比如:
- 用一个 token 顺手开放了对象列表;
- 下载接口没继续校验对象 token;
- 生成链接时不检查原始
read权限; - 把 portal token 当成长期用户身份令牌来复用。
实战里最该怎么排查
如果用户反馈“为什么这个链接没登录也能看”或“分享页翻页后行为怪”,建议按这个顺序查:
- 先确认当前链接对应的是不是某个
portal.mixin对象自己的 token; - 看入口是直接
access_url,还是通过/mail/view做受控跳转; - 检查下载、打印、相邻记录、附件这些派生 URL 是否继续带并校验对象 token;
- 区分 access token 与 partner hash,不要把对象权限和来访者身份混成一层;
- 回头看自定义有没有把单对象分享扩成列表、搜索或跨对象访问。
一句话记忆
Odoo Portal 的分享链接不是“免登录”,而是“把一条具体记录按最小必要能力做 token 化委托访问”;真正要守住的是对象级边界,而不是只盯着登录态。
DISCUSSION
评论区