7839

雑草魂エンジニアブログ

【Python】既存の PDF に画像を挿入する(PyPDF2 / ReportLab)

あるアプリケーション内で、Python を用いて Excel ファイルを作成し、その Excel ファイルを LibreOffice で PDF に変換していた。Excel のヘッダー/フッターには、文字・ロゴ画像を挿入していたが、LibreOffice で PDF 変換時に、なぜかヘッダーの画像だけが変換されないエラーが発生した。そこで、今回既存のPDFファイルに画像を挿入する機能を実装したので、備忘録として残しておく。

バージョン:Python-3.8

PyPDF2 / ReportLabとは

今回は、PyPDF2 と ReportLab の二つのライブラリを使って、実装を行うことにした。

Python の PDF 生成には様々な方法が存在するが、日本語の使用を前提とした場合、実績のあるライブラリは ReportLab であるように思えたので、今回選定に至った。

そして、PyPDF2 は日本語を含む PDF 生成の場合は文字化けなどが発生しやすいようであるが、PDFページの結合、回転など他のライブラリではできない処理を行うことができる。

今回の実装では、既存のPDFファイルの各ページのヘッダーの位置にロゴ画像を挿入することが目的のため、合成などせずに、各ページに直接ロゴ画像を挿入することもできた。しかしながら、同じロゴ画像を全てのページに挿入するのであれば、一度 ReportLab で PDF ファイルを作成しておけば、合成する処理のみで済むので、今回は合成する手段を選んだ。

インストール

pip で簡単にインストールすることができる。

pip install pypdf2
pip install reportlab 

画像を含んだ PDF 生成

ReportLab を用いて、PDFの生成を行う。メソッドの詳細は、ユーザーガイド を参照してほしい。

from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm

def create_header_logo_PDF():
    # 新規PDF作成
    pdf_canvas = canvas.Canvas('pdf_template/header_logo.pdf', pagesize=A4)
    # 画像を挿入する
    target_x, target_y = 10*mm, 277*mm
    pdf_canvas.drawImage('report-img/_logo_header.jpg', target_x, target_y)
    # 文字を挿入したい場合は、以下。
    # pdf_canvas.drawString(target_x, target_y,"Hello World")

    # PDF保存
    pdf_canvas.save()

canvas のデフォルトは、A4の設定になっているが、公式ドキュメントにもあるように、明示的に示すことがオススメされている。(A4サイズは、 210mm × 297mm となっている。)また、ReportLab のデフォルトの座標系は、紙の左下が原点となっているので、画像の位置を設定する場合は注意が必要である。

PDF 合成

PyPDF2 を用いて、PDFの合成を行い、新規 PDF ファイルを作成する。上記で作成した PDF ファイルを読み込んで、input_file_path で指定したPDFファイルに合成して、output_file_path に出力する。

from PyPDF2 import PdfFileWriter, PdfFileReader
def merge_pdf(input_file_path, output_file_path):
    # ヘッダーにロゴアイコンがあるPDF全体の読み込み
    header_logo_pdf = PdfFileReader(open("pdf_template/header_logo.pdf", "rb"))
 # ロゴアイコンがあるページ読み込み
    header_logo_page = header_logo_pdf.getPage(0)
    # 既存のファイル読み込み
    input_file = PdfFileReader(open(input_file_path, 'rb'), strict=False)
    # 既存のファイルのページ数を取得する
    page_count = input_file.getNumPages()
    # 新規の出力ファイル作成
    output_file = PdfFileWriter()
    # 既存の全体ページをループで回す
    for page_number in range(page_count):
        input_page = input_file.getPage(page_number)
        # トップページ以外
        if page_number:
            # 既存のページとヘッダーロゴをmergeする
            input_page.mergePage(header_logo_page)
            # 圧縮する
            input_page.compressContentStreams()
        # 出力ファイルにページを追加する
        output_file.addPage(input_page)
    # 出力ファイル保存
    with open(output_file_path, "wb") as outputStream:
        output_file.write(outputStream)

また、今回 merge した際に、かなり容量が増えたので、compressContentStreams() で圧縮処理を行うようにした。容量を確認した結果を以下に示す。

  • 元のPDFファイル:2.3M
  • PyPDF2で一度読み込み、再出力:2.3M
    • 変化なしで、処理として問題ないことが確認できた。
  • PyPDF2で12KBのPDFファイルと各ページをmergeしていく:26M
    • mergeページ数313ページなので、単純計算で 313 × 12K = 約3.7M 増加するはずなので、2.3M + 3.7M = 6M になる予定であった。
    • mergePageではあまりに大きくなりすぎる問題が発生。
  • page.mergePage()をしてから、page.compressContentStreams()することで圧縮する:2.5M
    • きちんと圧縮できて、ひと安心。

(おまけ)Doker環境

今回、私は Dcoker環境で開発を行っていたので、ReportLab を追加した。

FROM python:3.9-alpine3.12
# 省略
RUN apk --update add py3-reportlab

Pillowインストール時に以下のエラーが発生した。

Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/tmp/pip-install-_ufmrkz3/pillow_ab01fc49d3214bf78846d96525b3b440/setup.py", line 922, in <module>
    raise RequiredDependencyException(msg)
    __main__.RequiredDependencyException:
    The headers or library files could not be found for jpeg,
    a required dependency when compiling Pillow from source.

調べてみると、jpeg-dev zlib-dev のパッケージを追加する必要があるようであった。また、py3-reportlabでインストールされる Pillow のバージョンが 7.1 だったので、Python3.8までしか対応していないことがわかったので、Pythonのバージョンを下げることにした。

FROM python:3.8-alpine3.12
# 省略
RUN apk --update add py3-reportlab jpeg-dev zlib-dev

これで無事に、ReportLab が使えるようになった。

まとめ

無事に、既存の PDF ファイルの各ページのヘッダーにロゴ画像を追加することができた。

PyPDF2 を用いることで、簡単に PDF を合成することができたので、PDF にウォーターマークを入れたい場合などにも応用できると思えた。

それでは、ステキな開発ライフを。

関連記事

serip39.hatenablog.com

serip39.hatenablog.com

【Python】XlsxWriter を用いて整形したデータを Excel ファイルに出力する(逆引き)

先日、Python で簡単なデータ解析をして、解析した結果を Excel ファイルで出力するアプリケーションを作成した。その際に、XlsxWriter を用いて、Excel ファイルの作成・編集などを行ったので、備忘録として残しておく。

XlsxWriter とは

xlsxwriter.readthedocs.io

XlsxWriter は Excel 2007+ XLSX ファイルを作成する Python モジュールである。(Python 2.7, 3.4+ に対応している。)

PythonExcel を扱うモジュールとしては、XlsxWriter とは別に、OpenPyXL が有名である。

XlsxWriter と OpenPyXL の違いとしては、以下が挙げられる。

module 新規作成 既存ファイル編集
XlsxWriter ×
OpenPyXL

XlsxWriter では、既存Excelファイルの読み込みができないため、既存Excelファイルを編集する場合、OpenPyPl を使う必要がある。

ただし、今回私は XlsxWriter を選択することにした。

その理由としては、Excel ファイルのヘッダーに画像を挿入したいことが挙げられる。OpenPyXL では、現状の仕様ではヘッダーに画像を挿入できないようであった。XlsxWriter では挿入可能だったため、XlsxWriter を使用することにした。

ライブラリ逆引き

ライブラリの使い方に関して、詳細を 公式ドキュメント で確認して欲しい。ただ、実装にあたり、Excel のこの処理ってどのライブラリ使うの?って最初、私もなったので、今回、私が使用したライブラリを用途に応じて紹介するので、参考にして欲しい。

Excelファイル / シート作成

XlsxWriter の基本的な流れは、以下である。

  1. Excel ファイルであるworkbookを作成する
  2. 1.で作成したworkbookに、ワークシートworksheetを追加する
  3. ワークシートworksheet内を編集する
  4. Excel ファイルを上書きして、保存する
import xlsxwriter
# Excelファイル作成
workbook = xlsxwriter.Workbook(file_name)
# ワークシート作成
worksheet = workbook.add_worksheet(sheet_name)
# ワークシート編集
worksheet.write('A1', 'Hello world') #A1セルにHello Worldを入力する
# Excelファイル保存
workbook.close()  
import xlsxwriter
# Withを使うことで、close()する必要がなくなるのでオススメ
With xlsxwriter.Workbook(file_name) as workbook:
    # ワークシート作成
    worksheet = workbook.add_worksheet(sheet_name)
    # ワークシート編集
    worksheet.write('A1', 'Hello World') #A1セルにHello Worldを入力する

セルへの文字入力

セルに文字や式を入力する場合は、以下を使う。

worksheet.write(row, col, *args)

cell_format = workbook.add_format({'bold': True, 'italic': True})
worksheet.write(A1, 'Hello World', cell_format) 
worksheet.write(A2, '=SIN(PI()/4)', cell_format) 

Excel には文字以外にも、数字や数式などがあり、フォーマットを指定した入力方法も存在する write_number(), write_formula()が、write()ではどのフォーマットでも対応できるので、基本的にはこれを使えばいいと思う。

セルのformat(書体、中央寄せ等)は、一度add_formatFormat Object を定義しておき使う必要がある。

セルを結合させて、そこに文字や式を入力する場合は、以下を使う。

worksheet.merge_range(first_row, first_col, last_row, last_col, data[, cell_format])

merge_format = workbook.add_format({
  'font_name': 'メイリオ',
        'font_size': 8,
        'align': 'center',
        'valign': 'vcenter',
})
worksheet.merge_range('A1:C3', 'Hello World', merge_format)

また、Excel にデータを入力する場合は、基本的に大量の配列データである場合が多いと思う。その場合には、以下を使う。

表のヘッダーなどを設定する場合は、下記で簡単に設定できる。

header = ('title1', 'title2', 'title3', 'title4', 'title5')
worksheet.write_row('A1', header)

また、大量のデータを表示する場合は、以下のようにすることで表示が可能となる。

data_list = [
    {'name': 'test1', 'price': 200, 'num': 10, 'desc': 'desc1'},
    {'name': 'test2', 'price': 400, 'num': 20, 'desc': 'desc2'},
    {'name': 'test3', 'price': 600, 'num': 30, 'desc': 'desc3'}
]
table_format = workbook.add_format({
    "align": "center",
    "valign": "vcenter",
    "text_wrap": True,
    "border": 1,
})
for row, data in enumerate(data_list):
    worksheet.write_row(f"A{2+row}", data, table_format)

セルの幅・高さ調整

セルの幅や高さを調整したい場合は、以下を使う。初期設定は、height: 15, width: 8.43となっている。

worksheet.set_row(0, 20)  # Set the height of Row 1 to 20.
worksheet.set_column('A:B', 20)  # Column A-B width set to 20.
worksheet.set_column('C:C', 40)  # Column C width set to 40.

高さに関しては、全体的に変更したい場合が多いと思う。その場合は、set_default_row(height)を使う。

テキストボックス挿入

テキストボックスを挿入したい場合は、以下を使う。

worksheet.insert_textbox(row, col, textbox[, options])

worksheet.insert_textbox(
    "A2",
    "Hello World",
    {
        "width": 400,
        "height": 50,
        "font": {"name": "メイリオ", "size": 14},
        "fill": {"none": True},
        "line": {"none": True},
    },
)

テキストボックスのサイズ、位置、フォントのフォーマットなどを指定することができる。

画像挿入

画像を挿入したい場合は、以下を使う。

worksheet.insert_image(row, col, image[, options])

worksheet.insert_image('B2', 'python.png')
worksheet.insert_image('B2', 'python.png', {'x_offset': 15, 'y_offset': 10})
worksheet.insert_image('B2', 'python.png', {'x_scale': 0.5, 'y_scale': 0.5})

基本的に、座標系は全て左上を原点として考える必要がある。x_offsety_offsetを設定しなければ、図形の左上がB2の左上の角と一致するように配置される。また、画像の大きさは、x_scaley_scaleで設定することができる。

ヘッダー/フッターの設定

ヘッダー/フッターを設定する場合は、以下を使う。

worksheet.set_header(
    "&L&G&R&G",
    {
        "image_left": "report-img/header_logo_left.png",
        "image_right": "report-img/header_logo_right.png",
    },
)
worksheet.set_footer(
    '&L&"游ゴシック Regular, Regular"Copyright TEST Inc. All Rights Reserved.' + '&R &P'
)

上記の設定をすることで、ヘッダーの両端にロゴ画像を挿入、フッターの左にはCopyright、右にはページ番号を挿入することができる。

印刷範囲設定

Excel では表の一部を選択して印刷したり、見栄えよく1枚に収めたり、印刷したい範囲だけを指定することができる。

worksheet.print_area(first_row, first_col, last_row, last_col)

worksheet1.print_area('A1:H20')     # Cells A1 to H20.
worksheet2.print_area(0, 0, 19, 7)  # The same as above.

範囲指定は、行数と列数での指定も可能であるが、アルファベット を用いた範囲指定の方が容易であるように思う。(行数と列数は、ゼロ始まりなので注意が必要。)

印刷ページ設定(拡大縮小印刷)

任意の範囲を印刷設定する際に、Excel で拡大縮小印刷の設定をすることがあると思う。設定方法は2つある。(同時に2つを設定することはできず、どちらかを設定することができる。)

  1. 任意の拡大縮小設定を行う
  2. 用紙のサイズに合わせて拡大縮小を行う

1. 任意の拡大縮小設定を行う

worksheet.set_print_scale(int)

worksheet1.set_print_scale(10)
worksheet2.set_print_scale(80)
worksheet3.set_print_scale(400)

拡大縮小できる範囲は、10 ~ 400 となっている。

2. 用紙のサイズに合わせて拡大縮小を行う

worksheet.fit_to_pages(width, height)

worksheet1.fit_to_pages(1, 1)  # Fit to 1x1 pages.
worksheet2.fit_to_pages(1, 2)  # Fit to 1x2 pages.
worksheet3.fit_to_pages(1, 0)  # 1 page wide and as long as necessary.

印刷タイトル設定

複数ページにわたる表を Excel で見やすくするために、表のヘッダーを印刷タイトルに設定したい場合があると思う。

worksheet.repeat_rows(first_row[, last_row])

worksheet1.repeat_rows(0)     # Repeat the first row.
worksheet2.repeat_rows(0, 1)  # Repeat the first two rows.

1行の場合は、first_row のみを設定すればいい。複数行の場合は、last_rowを設定する。(ただし、行数が「0」始まりとなっているので、実際に設定したい行数から - 1 した値を設定する必要があるので、注意が必要である。)

まとめ

Python で簡単に Excel へのデータ出力を行うことができた。セルの幅なども調整して、印刷できるところまできちんと設定できたので、とても便利であった。

それでは、ステキな開発ライフを。

【Rails】DB を使わずに、jsonファイルで簡易ユーザー管理機能を実装する

ある Rails アプリケーションにおいて、下図のように、1つの DB を複数のアプリケーションが参照する場合がある。(ただし、複数のアプリケーションから、自由に DB の書き込みができるのではなく、1つのみに Read/Write 権限を持たせて、その他は Read 権限しかない。)

f:id:serip39:20201230080142p:plain

この場合に、DB の Read 権限しかない Rails アプリケーションでは、主にデータの可視化だけを行っている。そして、データの可視化において、ログインユーザーに応じて表示を変えたいという話になり、本題の DB を使わずに、jsonファイルで簡易ユーザー管理を実装したので、今回紹介する。(マルチDBでの実装も検討したが、一旦はログインユーザー管理のみとのことだったので、jsonファイルでの代用で押し切ることとした。)

Rails のユーザー管理といえば、device だと思うが、今回、deviceライクな実装になるようにしてみた。

jsonファイル/ディレクトリ構成

今回、jsonファイルは DB と同じ扱いをするため、Rails.root/db に配置することとした。

Rails.root
├─ app
├─ db
│  └─ user.json
└─ Gemfile

簡単であるが、試験的に2人のユーザーを設定した。

{
  "members": [
    {
      "user_id": "admin",
      "name": "管理者",
      "password": "admin",
    },
    {
      "user_id": "test1",
      "name": "テストユーザー1",
      "password": "test1",
    }
  ]
}

Uerモデルを作る

Userモデルでは、以下を考慮している。

  • ログイン画面から、ID/PASSで認証できる
  • ログイン後には、sessionにID/PASSを暗号化したaccess_tokenを保存しておき、リロード時には自動的にログインさせる(復号化時に使用した OpensslUtil に関しては、後ほど紹介する)
  • membersのデータを都度読み込むのではなく、インスタンス生成時に、読み込ませるようにしている
class User
  include ActiveModel::Model

  def initialize(user_id: "", password: "", name: "")
    @user_id = user_id
    @password = password
    @name = name
    @members = []
    # jsonファイルのmembersを読み込む
    File.open("#{Rails.root}/db/user.json") do |file|
      hash = JSON.load(file)
      @members = hash["members"]
    end
    return @members
  end

  attr_accessor :user_id, :name, :password
  validates :user_id, presence: true
  validates :name, presence: true
  validates :password, presence: true

  # ログインユーザーを認証する
  def authenticated?
    @members.any? {|member| member["user_id"] == @user_id && member["password"] == @password}
  end

  # sessionのaccess_tokenから自動で再ログインさせる
  def present?(access_token)
    str_decrypted_token = OpensslUtil.aes_decrypt(access_token)
    str_arr = str_decrypted_token.split("::")
    current_user = @members.find {|member| member["user_id"] == str_arr[0] && member["password"] == str_arr[1]}
    if !current_user.blank?
      @user_id = current_user["user_id"]
      @password = current_user["password"]
      @name = current_user["name"]
      return self
    else
      return false
    end
  end
end

include ActiveModel::Model を追加しておくことで、Action PackやAction Viewと連携する以下の機能を使うことができるようになる。

  • モデル名の調査
  • 変換
  • バリデーション

また、form_forやrenderなどのAction Viewヘルパーメソッドも使えるようになるので、便利である。

routesを設定する

ログイン画面、ログイン処理、ログアウト処理ができるように、ルートを追加する。

Rails.application.routes.draw do
  <-- 省略 -->
  controller :sessions do
    get    'login' => :new #ログイン画面表示
    post   'login' => :create #フォームに入力されたID/PASSでログインを行う
    delete 'logout' => :destroy #ログアウトを行う
  end
end

Controller を作る

全てのControllerで、ログインユーザーである current_user を参照したいので、application_controller.rb に current_user を定義する。

また、before_action :login_requiredを行うことで、ログインユーザーでない場合は、ログイン画面にリダイレクトさせる。

class ApplicationController < ActionController::Base
  helper_method :current_user
  before_action :login_required

  private

  def current_user
    @current_user ||= User.new().present?(session[:access_token]) if session[:access_token]
  end

  def login_required
    redirect_to login_path status: :unauthorized unless current_user
  end
end

SessionsController に関しては、ログインしていない場合にログイン画面を表示させたいので、skip_before_action :login_requiredを追加しておく必要がある。

また、認証された場合には、#{user_id}::#{password} の文字列として暗号化を行い、sessionに保存する。(暗号化時に使用した OpensslUtil に関しては、後ほど紹介する)

class SessionsController < ApplicationController
  skip_before_action :login_required

  def new
  end
  #ログイン処理
  def create
    login_user = User.new(user_id: params[:user_id], password: params[:password])
    if login_user.authenticated? #認証された場合
      session[:access_token] = OpensslUtil.aes_encrypt("#{login_user.user_id}::#{login_user.password}")
      redirect_to(root_path) && return
    else
      render :new, status: :bad_request
    end
  end
  #ログアウト処理
  def destroy
    session.delete :access_token
    redirect_to action: :new
  end
end

Viewを作る

ログイン画面を作る

.row.justify-content-center
  .col-xl-5.col-lg-6.col-md-9
    .card.o-hidden.border-0.shadow-lg.my-5
      .card-body.p-0
        .row
          .col-lg-12
            .p-5
              = form_tag login_path, method: :post, class: "user" do
                .form-group
                  = text_field_tag(:user_id, nil, class: "form-control form-control-user", placeholder: "ユーザーID", required: true, value: params[:user_id])
                .form-group
                  = password_field_tag(:password, nil, class: "form-control form-control-user", placeholder: "パスワード", required: true)

                button.btn.btn-primary.btn-user.btn-block(type="submit") ログイン

暗号化・復号化に関して

暗号化・復号化のメソッドに関しては、modelやcontrollerでも使いたかったので、Rails.root/lib/utils/の配下に作成することとした。

Rails.root/lib/utils/ を初回起動時に、読み込むように config/application.rb に以下を追記する。

module App
  class Application < Rails::Application
    # ~ 省略 ~
    # Autoload utils
    config.paths.add 'lib/utils', eager_load: true
  end
end

暗号化と復号化に関しては、OpenSSL を使って実装している。

class OpensslUtil
  SALT = "AM3hFSF0MLkTgKRG6U418gBC"
  PASSWORD = "TEST1234"
  BIT = 256
  # ======================================
  # <暗号化>
  # ======================================
  def self.aes_encrypt(plain_text)
    enc = OpenSSL::Cipher::AES.new(BIT, :CBC)
    enc.encrypt
    salt = Base64.decode64(SALT)
    key_iv = OpenSSL::PKCS5.pbkdf2_hmac(PASSWORD, salt, 2000, enc.key_len + enc.iv_len, "sha256")
    enc.key = key_iv[0, enc.key_len]
    enc.iv = key_iv[enc.key_len, enc.iv_len]
    encrypted_text = enc.update(plain_text) + enc.final
    Base64.encode64(encrypted_text).chomp
  end
  # ======================================
  # <復号>
  # ======================================
  def self.aes_decrypt(encrypted_text)
    encrypted_text = Base64.decode64(encrypted_text)
    salt = Base64.decode64(SALT)
    dec = OpenSSL::Cipher::AES.new(BIT, :CBC)
    dec.decrypt
    key_iv = OpenSSL::PKCS5.pbkdf2_hmac(PASSWORD, salt, 2000, dec.key_len + dec.iv_len, "sha256")
    dec.key = key_iv[0, dec.key_len]
    dec.iv = key_iv[dec.key_len, dec.iv_len]
    dec.update(encrypted_text) + dec.final
  end
end

sessionの保存期間を設定する

access_tokenを保存している session の保存期間を設定するために、config/initializers/session_store.rb を作成する。今回は、保存期間を1時間とした。

# Set Expired Session
# https://edgeapi.rubyonrails.org/classes/ActionDispatch/Session/CookieStore.html
Rails.application.config.session_store :cookie_store, expire_after: 1.hour

まとめ

今回は、ちょっとイレギュラーな DB を使わずに、jsonファイルで簡易ユーザー管理機能を実装した。deviceライフな感じにしたので、使い勝手としても悪くない気がしている。ただし、Token管理やログイン履歴まではできないので、本当に簡易版での使用用途になると思われる。

それでは、ステキな開発ライフを。

【Python/JWT】LINE WORKS の BOT の AccessToken を取得する

今回、LINE WORKS に新しく BOT を導入することになった。BOTの用途は、通知用であり、インタラクティブである必要はない。

通常の LINE の BOT を作成したことはあったが、LINE WORKS での BOT 作成は初だったので、備忘録として残しておく。

システム概要

今回、LINE WORKS + Power Automate(Teams)であるカレンダーの予定を抽出して、BOT で通知させることを想定している。概略図は以下の通りである。

f:id:serip39:20201230035719p:plain

今回の記事では、LINE WORKS のセットアップ部分、特にAccess_Tokenの取得について紹介する。

LINE WORKS の BOT 作成手順

公式ドキュメントに事細かに丁寧に書かれているので、詳細な手順は公式ドキュメントを是非見てほしい。

そして、1 の Server Token の発行手順は以下のようになっている。

  1. JWT 生成
  2. JWT 電子署名(signature)
  3. LINE WORKS 認証サーバーへの Token リクエス

f:id:serip39:20201230040542p:plain

今回、(Power Automate に組み込む前に)Pythonaccess_token の取得及びメッセージ送信試験を行った。(access_tokenは、24 時間以内利用されない場合には自動的に失効する。今回のシステムの仕様では、毎日使用するため、access_tokenが失効する可能性は低い。access_tokenを取得して、そのまま Power Automate に組み込むことを想定している。)

なお、実装したサンプルコードは GitHub に公開している。 pipenv を使って、Python仮想環境(開発環境)を即座に構築するだけで、サンプルプログラムの動作確認ができるようにしているので、どうぞ。

github.com

pipenv に関しては、以前の【Python】Pipenvを用いたPython仮想環境(開発環境)を参考にして欲しい。

プログラムを実行する前に、グローバル変数、LINE WORKSのBOTに使用する以下の設定パラメータを追記する必要がある。

# グローバル変数
DOMAIN_ID = ""
API_ID = ""
SERVER_API_CONSUMER_KEY = ""
SERVER_LIST_ID = ""
SERVER_LIST_PRIVATEKEY = ""
BOT_NO = ""

それぞれの値は、Developer Console で確認することができる。(事前に、BOTなどもこの時点で作成しておく必要がある。)

SERVER_LIST_PRIVATEKEY には、Server List(ID登録タイプ)の認証キーを設定するが、Developer Consoleではprivate_[日付].key のファイルがダウンロードされ、以下のように数行になっている。

-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0B
〜省略〜
+juq+vKHrY7+CQgr/7bjZqmR
-----END PRIVATE KEY-----

このままだとグローバル変数に登録することができないので、改行されている部分を全て改行コードに書き換えて、一行とする。

$  cat private_20201229171641.key | sed -e :loop -e 'N; $!b loop' -e 's/\n/\\n/g' | pbcopy

上記のコマンドを実行することで、クリップボードにコピーされた状態になるので、そのままグローバル変数の部分にペーストする。

SERVER_LIST_PRIVATEKEY = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0B (省略)+juq+vKHrY7+CQgr/7bjZqmR\n-----END PRIVATE KEY-----"

全ての設定が完了次第、python get_token.py を実行することで BOT から「Hello World」と通知されれば完璧である。

ログから、access_tokenを確認することもできる。

$ python get_token.py
{'action': 'create jwt', 'status': 'success', 'message': 'JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJiYTY3M2ZhOTBiNjQ0YWU2OGI4MjliNzIxM2MxMjFkYiIsImlhdCI6MTYwOTI2OTg3MiwiZXhwIjoxNjA5MjcxNjcyfQ.TlA5A86qKH6cQ4vKmKtpulhBYxZbxIg6fSLLYLNePqFb59uXpnte0etG0jsv2AyDquDcFZlUg62baHQil8V0mngo_OUcX51llQK3-Cx5BBahYSuMVU_FwMcTrtwJfBp0zLHRxpy5hrifTnafuzrRXbhrPixELnxYVoRGHglDbn39kmbylU-1zg5Fr1w__obiwUOSC2J80WmuwO8ugjoLx67xKYCuInFpyMGAG7zdRJLeoyeAuRBy7wA99LSLMS9vzdrDvGnzZjlP5etF2pDo7ZUbQOUSqFNin9CHVPdlOmW2rifQxRMsO5ZnNAtAZoHXpZBjgFQwei8xjuoYW0sILA'}
{'action': 'get access token', 'status': 'success', 'message': 'access_token:AAABCVee9HJEVlsdbn/egcTZVN+Cw3+c2VydmVySWQ+O8Up4ja2mud53VSj911kHu70OZ5CJ5Bi6bRcwPJE87ExNCZ/BGo4b13uYu4vsFn9qHUD5zZpILYKFb1c8k2mUUrLnc7PMooaWqyOAagGNugfiOepzWzdaMGNEWD2Dg/85wFO7rKjoUrsNiZOUCIKWR25vvbpEoKF2WW1xAYl8yZPFTwbPn9VfgTGsPoPtOlsEBPjUjp6e1WBTedVTsYlV3B17zz59Yb7n2iNYzMubw1/DAbEznTQYPsvoeiSggVMt3t6IguUGuXJIocD+qpZ59rmxIXO9tZ2yFj3BudqTo5uz5AHD07J2nTl9LJJC3wr6jtCqjnm/tTxH6TVPG'}

JWT とは

jwt.io

JWT とは、JSON Web Tokenの略である。(ジョットと読むらしい。)以下の3つの要素から構成され、それぞれは「.」で結合されている。また、それぞれは Base64エンコードされている必要がある。

  • ヘッダー:暗号化アルゴリズムトークンタイプなどのメタ情報
  • クレーム情報:任意の情報を付与できる(ユーザー認証情報など)
  • 署名:ヘッダーで指定した暗号化アルゴリズムで、秘密鍵を用いて生成される
[ヘッダー].[クレーム情報].[署名]

JWTの作り方

1. アルゴリズムとTokenタイプをヘッダに設定して、Base64エンコードする

$ echo -n '{"alg": "RS256", "typ": "JWT"}' | base64
eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCJ9

2. クレーム情報を作成して、Base64エンコードする

$ echo -n '{"iss":"ba673fa90b644ae68b829b7213c121db","iat": 1609274027, "exp": 1609275827}' | base64
eyJpc3MiOiJiYTY3M2ZhOTBiNjQ0YWU2OGI4MjliNzIxM2MxMjFkYiIsImlhdCI6IDE2MDkyNzQwMjcsICJleHAiOiAxNjA5Mjc1ODI3fQ==
パラメータ 説明
iat JWT 生成日時(UNIX、単位:sec)
exp JWT 満了日時(UNIX、単位:sec)
iss LINE WORKSで発行されたサーバーIDなど

事前に用意されているのは、こちらのパラメータ である。

3. ヘッダとペイロードを . (ドット) で結合し、署名なしTokenを生成する

eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiJiYTY3M2ZhOTBiNjQ0YWU2OGI4MjliNzIxM2MxMjFkYiIsImlhdCI6IDE2MDkyNzQwMjcsICJleHAiOiAxNjA5Mjc1ODI3fQ==

4. 署名なしTokenに対し、秘密鍵を用いて署名を生成する

openssl dgst電子署名を生成する。

$ echo -n 'eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiJiYTY3M2ZhOTBiNjQ0YWU2OGI4MjliNzIxM2MxMjFkYiIsImlhdCI6IDE2MDkyNzQwMjcsICJleHAiOiAxNjA5Mjc1ODI3fQ==' | \
openssl dgst -sha256 -sign private_20201229171641.key -binary | \
base64
W//2sawtLzg3GfH6GkeS1nw947ec5qiynl2pibztjVycpe73KcENXt6Z4aftfUgMEjHRvX3N8i754E9rBffI4gfarQzY+nhUVdxP9zFXSxr7YPXoPx9A9bL4bjNRvkKJV2BRLlJkOQ6g9TY1Uu5mtPsyTZ6zy2+hXa8h8tn0dSfsLF4uH0nOiEt6p44tNz6U8/0jUlPNd3tt3W7F4MZy038nMazYQ6g5e1W7tfgqCtV0NVQUGpZdKKh0HvWf8qiqE5cTJ6y+eREENZMAEjPY6rxx19PVix1tipZ18MYm1MpCAAzFhZFXsOU+/FK295/tvIJ3g6ehgU7cHRraiGQzbg==

5. 署名なしTokenと署名を . (ドット) で結合する

eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiJiYTY3M2ZhOTBiNjQ0YWU2OGI4MjliNzIxM2MxMjFkYiIsImlhdCI6IDE2MDkyNzQwMjcsICJleHAiOiAxNjA5Mjc1ODI3fQ==.W//2sawtLzg3GfH6GkeS1nw947ec5qiynl2pibztjVycpe73KcENXt6Z4aftfUgMEjHRvX3N8i754E9rBffI4gfarQzY+nhUVdxP9zFXSxr7YPXoPx9A9bL4bjNRvkKJV2BRLlJkOQ6g9TY1Uu5mtPsyTZ6zy2+hXa8h8tn0dSfsLF4uH0nOiEt6p44tNz6U8/0jUlPNd3tt3W7F4MZy038nMazYQ6g5e1W7tfgqCtV0NVQUGpZdKKh0HvWf8qiqE5cTJ6y+eREENZMAEjPY6rxx19PVix1tipZ18MYm1MpCAAzFhZFXsOU+/FK295/tvIJ3g6ehgU7cHRraiGQzbg==

これで完成である。

上記をまとめると、以下のようになる。

$ header=`echo -n '{"alg":"RS256","typ":"JWT"}' | base64`
$ claim=`echo -n '{"iss":"ba673fa90b644ae68b829b7213c121db","iat": 1609274027, "exp": 1609275827}' | base64`
$ signature=`echo -n $header.$payload | openssl dgst -sha256 -sign private_20201229171641.key -binary | base64`
$ echo $header.$claim.$signature

この処理を簡単に実装できるのが、pyjwt というライブラリである。

pyjwt.readthedocs.io

とても使いやすく、わかりやすかった。本当に感謝である。

(おまけ)Power Automate の設定

とても簡単に連携の設定ができたので、こんなこともできるのかと、誰かの参考になれば嬉しい。

今回は、カレンダーに入っている予定の内「打ち合わせ」をタイトルに含んでいた場合は、その予定表のリンクを LINE BOT に通知してもらうようにした。ノーコードで細かく色々と設定できるのは本当に使いやすいと思えた。設定に関しては、言葉で説明するより、実際に画面をみてもらう方が早いと思うので、参考までに共有しておく。(「予定しているイベントが間もなく開始されるとき (V3)」というトリガーのお陰で、本当に簡単に実装することができた。)

f:id:serip39:20201230074254p:plain

まとめ

今回は、LINE WORKS の BOTAPI を試してみた。JWT でのToken リクエストに関しても、理解を深めることができた。

それでは、ステキな開発ライフを。

【OpenSSL】オレオレ(自己署名)証明書を作成する(EC2)

EC2 のステージング環境で SSL 対応がしたいとの要望があり、まだSSL サーバー証明書の購入ができていなかったこともあり、暫定でオレオレ(自己署名)証明書を作成した。今回は、その備忘録を残しておく。

オレオレ(自己署名)証明書とは

通常、SSL サーバー証明書は任意の認証局(CA)に認証してもらうことで発行される。(認証局を使わせてもらうのに、費用が発生する。)しかしながら、自分でプライベート認証局(CA)を立てて、自分でSSL サーバー証明書を発行することで、仮の認証をしてしまえるのが、オレオレ(自己署名)証明書である。

処理の流れは以下である。

  1. プライベート認証局(CA)を立てる
  2. オレオレ(自己署名)証明書を発行する
  3. 利用するブラウザに、プライベート認証局(CA)を認めさせる

今回は、EC2 のパブリック IPv4 DNSドメインである「amazonaws.com」のワイルドカードSSLサーバー証明書「*.amazonaws.com」を作成してみた。

OpenSSLのインストール

EC2の場合は、defaultでインストールされていたが、もしインストールされていない場合は、インストールが必要である。

$ openssl version
OpenSSL 1.0.2k-fips  26 Jan 2017

OpenSSLの設定

以降は、root ユーザーで処理を行うこととする。今回、自作するプライベート認証局を「nginx」というフォルダの中に作成する。(名前は何でもいいが、今回の web サーバーが Nginx のため、nginxとした。)

# rootユーザー切り替え
$ sudo -s
# ディレクトリ移動
$ cd /etc/pki
# defaultのCA設定フォルダを自作する認証局「nginx」にコピーする
$ cp -r CA nginx
# opensslのdefaultの設定ファイルを nginx/ 配下にコピーする
$ cp tls/openssl.cnf nginx/.

openssl.cnf で参照設定がされているファイルを nginx/ 配下に作成する。

$ cd nginx
$ touch index.txt
$ touch index.txt.attr
$ echo 00 > serial
$ echo "subjectAltName=DNS:*.amazonaws.com" > san.ext

subjectAltNameDNSは、openssl.cnfcommonName と同じにする。

最近のブラウザでは、アクセスするドメイン名を証明書の一般名commonNameで判定せず、SAN(Subject Alternative Name)DNS Nameで判定する動作になっているため、SANを設定しておく必要がある。

openssl.cnf を編集する。

$ vim openssl.cnf

変更箇所のみを明示する。

[ CA_default ]
dir             = /etc/pki/nginx #ディレクトリ変更

[ req_distinguished_name ]
countryName_default             = JP #任意に設定
stateOrProvinceName_default    = Tokyo #任意に設定
localityName_default            = Minato-ku #任意に設定
0.organizationName_default      = Test Co. Ltd #任意に設定
organizationalUnitName_default  = Development #任意に設定
commonName                      = *.amazonaws.com #任意に設定

[ usr_cert ]
subjectAltName=@alt_names #末尾に追記する

プライベート認証局(CA)を立てる

1. CAの秘密鍵作成 プライベート認証局(CA)用の秘密鍵を生成する。秘密鍵に設定するパスワードを聞かれるので入力する。

$ openssl genrsa -aes256 -out ./private/cakey.pem 2048

2. CAのCSR(証明書発行要求ファイル)作成 CAの証明書を発行するためのCSRを作成する。(再度、秘密鍵に設定したパスワードを聞かれるので入力する。)

$ openssl req -new -config ./openssl.cnf -key ./private/cakey.pem -out ./cacert.csr

その後、色々質問されるが、基本的にはそのまま Enter で問題ない。(openssl.cnf に設定したので。)ただし、「Common Name」はきちんと設定できていないと、後々エラーが出るので、きちんと設定しておくことをオススメする。

3. CAの証明書作成 CAの証明書を発行する。(再度、秘密鍵に設定したパスワードを聞かれるので入力する。)

$ openssl x509 -in ./cacert.csr -req -signkey ./private/cakey.pem -out ./cacert.pem

オレオレ(自己署名)証明書を発行する

1. サーバー用の秘密鍵作成 サーバー用に秘密鍵を生成する。秘密鍵に設定するパスワードを聞かれるので入力する。(後ほど、パスワードは解除するので適当で大丈夫。)

$ openssl genrsa -aes256 -out ./private/server-with-password.key 2048

2. パスワード解除した秘密鍵作成 生成した秘密鍵から、パスワードを解除した秘密鍵を作成しておく。(再度、秘密鍵に設定したパスワードを聞かれるので入力する。)

$ openssl rsa -in ./private/server-with-password.key -out ./private/server.key

3. サーバー用のCSR(証明書発行要求ファイル)作成 サーバー用の証明書を発行するためのCSRを作成する。

$ openssl req -new -key ./private/server.key -config openssl.cnf -out server.csr

4. オレオレ(自己署名)証明書作成 オレオレ(自己署名)証明書を作成する。

$ openssl ca -config ./openssl.cnf -in ./server.csr -out ./server.crt -extfile san.ext

エラーがなく、y/n の質問を2回されるのでどちらも「y」で回答すれば、無事にオレオレ(自己署名)証明書が作成される。

利用するブラウザに、プライベート認証局(CA)を認めさせる

1. CAの証明書をダウンロードする EC2から、ローカルのデスクトップに、cacert.pem をダウンロードする。

$ scp ec2-user@<--EC2のIPアドレス-->:/etc/pki/nginx/cacert.pem ~/Desktop

2. ブラウザに設定する

  1. Chromeブラウザで、chrome://settings/securityにアクセスする。
  2. 「証明書の管理」を選択する。
  3. Macの場合は、キーチェーンのウィンドウが開くので、プラスボタンを押して、CAの証明書(cacert.pem)の登録を行う

Nginx に設定する

最後に、Nginx に生成したオレオレ(自己署名)証明書を設定をする。

server {
  listen 80;
  listen 443 ssl; #追記する
  server_name _;
  # SSLを追記する
  ssl_certificate /etc/pki/nginx/server.crt;
  ssl_certificate_key /etc/pki/nginx/private/server.key;
  ssl_client_certificate /etc/pki/nginx/cacert.pem;
}
# Nginx の設定に誤りがないか確認する
$ sudo nginx -t
# 再起動して設定を適応する
$ sudo systemctl reload nginx

まとめ

今回は、きちんとオレオレ証明書を作成することができた。いつもなんとなく設定していた SSL に関して、自分で作成することで、認証局などの仕組み含め、全体的に理解することができて、いい機会となった。

それでは、ステキな開発ライフを。