100行代码实现前端安全水印

75 阅读3分钟

在公司内部,一些OA系统为了防止信息泄露,往往会添加水印,这类水印需求是广泛而简单的。往往是leader一句话的事情。
作为程序猿(媛)本身开发工作已经很繁重,既然是leader一句话需求,那用简单的100行代码来实现也不为过吧。

不安全的实现

思路:Canvas or Svg + background
这是最简单的水印实现的,原理就是创建一个canvas或svg图片,然后把图片作为body的background。实现代码如下:

export interface ICanvasWM {
  container?: HTMLElement;
  width?: string;
  height?: string;
  textAlign?: CanvasTextAlign;
  textBaseline?: CanvasTextBaseline;
  font?: string;
  globalAlpha?: number;
  fillStyle?: string | CanvasGradient | CanvasPattern;
  content?: string;
  rotate?: number;
  zIndex?: number;
}

const defaultParam: ICanvasWM = {
  container: document.body,
  width: '200px',
  height: '200px',
  textAlign: 'center',
  textBaseline: 'middle',
  font: '18px Microsoft Yahei',
  globalAlpha: 0.04,
  fillStyle: 'black',
  content: '请勿外传',
  rotate: 30,
  zIndex: 1000,
};

const MAX_RECOVER_TIME = 100;
let recoverTime = 0;
/**
 * canvas 实现水印
 * @param param: ICanvasWM
 */
function CanvasWM(param: ICanvasWM = defaultParam): void {
  const wmParam = Object.assign(defaultParam, param);
  const { container, width, height, textAlign, textBaseline, font, fillStyle, globalAlpha, content, rotate, zIndex }
    = wmParam;
  const canvas = document.createElement('canvas');
  canvas.setAttribute('width', width);
  canvas.setAttribute('height', height);
  const ctx: CanvasRenderingContext2D = canvas.getContext('2d');

  ctx.textAlign = textAlign as CanvasTextAlign;
  ctx.textBaseline = textBaseline as CanvasTextBaseline;
  ctx.font = font;
  ctx.fillStyle = fillStyle;
  ctx.globalAlpha = globalAlpha;
  ctx.rotate((Math.PI / 180) * rotate);
  ctx.fillText(content, parseFloat(width) / 2, parseFloat(height) / 2);

  const base64Url = canvas.toDataURL();
  const wmDom = document.querySelector('.__wm');

  const watermarkDiv = wmDom ?? document.createElement('div');
  const styleStr = `
        position:absolute;
        top:0;
        left:0;
        width:100%;
        height:100%;
        z-index:${zIndex};
        pointer-events:none;
        background-repeat:repeat;
        background-image:url('${base64Url}')`;

  watermarkDiv.setAttribute('style', styleStr);
  watermarkDiv.classList.add('__wm');

  if (!wmDom) {
    container.style.position = 'relative';
    container.insertBefore(watermarkDiv, container.firstChild);
  }
}
export default CanvasWM;
  • 优点:简单,去掉ts类型,60行代码搞定
  • 缺点:绝对的不安全,F12打开页面源,手动删除body的background属性就能消除水印

安全的实现

思路:Canvas or Svg + MutationObserver

只需在不安全的方法基础上增加 MutationObserver 监听,重新覆盖用户对水印dom的修改动作,就能达到用户无法通过修改dom而去除水印的目的。
附上完整代码:

export interface ICanvasWM {
  container?: HTMLElement;
  width?: string;
  height?: string;
  textAlign?: CanvasTextAlign;
  textBaseline?: CanvasTextBaseline;
  font?: string;
  globalAlpha?: number;
  fillStyle?: string | CanvasGradient | CanvasPattern;
  content?: string;
  rotate?: number;
  zIndex?: number;
}

const defaultParam: ICanvasWM = {
  container: document.body,
  width: "200px",
  height: "200px",
  textAlign: "center",
  textBaseline: "middle",
  font: "18px Microsoft Yahei",
  globalAlpha: 0.04,
  fillStyle: "black",
  content: "请勿外传",
  rotate: 30,
  zIndex: 1000,
};

const MAX_RECOVER_TIME = 100;
let recoverTime = 0;
/**
 * canvas 实现水印
 * @param param: ICanvasWM
 */
function CanvasWM(param: ICanvasWM = defaultParam): void {
  const wmParam = Object.assign(defaultParam, param);
  const {
    container,
    width,
    height,
    textAlign,
    textBaseline,
    font,
    fillStyle,
    globalAlpha,
    content,
    rotate,
    zIndex,
  } = wmParam;
  const canvas = document.createElement("canvas");
  canvas.setAttribute("width", width);
  canvas.setAttribute("height", height);
  const ctx: CanvasRenderingContext2D = canvas.getContext("2d");

  ctx.textAlign = textAlign as CanvasTextAlign;
  ctx.textBaseline = textBaseline as CanvasTextBaseline;
  ctx.font = font;
  ctx.fillStyle = fillStyle;
  ctx.globalAlpha = globalAlpha;
  ctx.rotate((Math.PI / 180) * rotate);
  ctx.fillText(content, parseFloat(width) / 2, parseFloat(height) / 2);

  const base64Url = canvas.toDataURL();
  const wmDom = document.querySelector(".__wm");

  const watermarkDiv = wmDom ?? document.createElement("div");
  const styleStr = `
          position:absolute;
          top:0;
          left:0;
          width:100%;
          height:100%;
          z-index:${zIndex};
          pointer-events:none;
          background-repeat:repeat;
          background-image:url('${base64Url}')`;

  watermarkDiv.setAttribute("style", styleStr);
  watermarkDiv.classList.add("__wm");

  if (!wmDom) {
    container.style.position = "relative";
    container.insertBefore(watermarkDiv, container.firstChild);
  }

  const { MutationObserver } = window;
  if (MutationObserver) {
    let mo = new MutationObserver(() => {
      const wmDomOld = document.querySelector(".__wm");
      // 只在__wm元素变动才重新调用 CanvasWM
      if (
        ((wmDomOld && wmDomOld.getAttribute("style") !== styleStr) ||
          !wmDomOld) &&
        recoverTime < MAX_RECOVER_TIME
      ) {
        // 避免一直触发
        mo.disconnect();
        mo = null;
        recoverTime += 1;
        CanvasWM(wmParam);
      }
    });
    mo.disconnect();
    mo.observe(container, {
      attributes: true,
      subtree: true,
      childList: true,
    });
  }
}

export default CanvasWM;

Double安全的方式

思路:Shadow Dom + MutationObserver

为了提高水印的隐蔽性,同时避免受外部代码影响,从而在一定程度上防止篡改,可以考虑把水印元素放在 Shadow DOM 中。

来看下 Shadow DOM 的基本用法。使用 Element.attachShadow() 方法来将一个 shadow root 附加到任何一个元素上。它接受一个配置对象作为参数,该对象有一个 mode 属性,值可以是 open 或者 closed 。open 表示可以通过页面内的 JavaScript 方法来获取 Shadow DOM。而 closed 则表示不可以从外部获取 Shadow DOM

const shadowDom = Element.attachShadow({mode: 'closed'}); 
shadowDom.innerHTML = xxx

实现基本和第二种方式一致,只是把水印挂载点换成了Shadow DOM,详细代码就不再贴出了。

总结

  1. 本文讲的都是可见水印的实现方式,至于”盲水印“的实现大家可以参考网上其他资料;
  2. 安全水印的核心是MutationObserverAPI,用于监听用户动作,防止删除/篡改水印节点;
  3. 使用Shadow DOM做安全加固是为了进一步提高找到水印节点的成本,并且防止外部样式影响到水印。