Web備忘録

雑多に書いています

新しいMacを入手したときにやっておくべき環境構築

新品のMacを手に入れたとき、初期設定では色々と使いづらいので、いろいろとカスタマイズしていくと便利です。

そのカスタマイズの内容は多岐にわたるので、自分用も兼ねてメモしておこうと思います。

入社初日などをイメージしてます。基本的に、上から行うと時間のロスが少ないように記述しています。

Mac自体のシステム環境設定

トラックパッド

「軌跡の速さ」を最大にする。

キーボード

「キーのリピート」を最速に、「リピート入力認識までの時間」を最短にする。

アクセサシビリティ

「マウスとトラックパッド」のトラックパッドオプションより、「3本指のドラッグ」をオンにする。

デスクトップとスクリーンセーバー

スクリーンセーバで、「ホットコーナー」を設定する。(マウスカーソルを画面の4隅に移動させると、特殊な動作を行える設定)

(自分は左上を「misstion controller」に設定してます。)

ソフトウェアのインストール

あとからチマチマ入れるより最初にバンバンインストールしておく、これ大事。

Chrome

Safariさんバイバイ。

Google日本語入力

備え付けのapple日本語入力よりこっちのほうが性能がいいと思います。 自分はライブ変換もオフにしてます。(google日本語入力だとそもそもなかったかも)

Slack

コミュニケーションツール。落としておきましょう。

iTerm2

分割できたり、文字の色を変更できるターミナル(という認識であってるのだろうか)。

エディタ(VScodeVimAtomSublimeなど)

もちろん必須。入れれるならプラグインも入れてエディタを強化したい。

Docker

DockerIDの作成を要求されますが、Dockerのために我慢して作りましょう。

SQLクライアント、GUIツール(SequelProなど)

MySQLに強いSequelPro以外だと、無料でよいGUIツールを僕は知りませんが、使えるものがあるなら使うのがよさげ。

Postman

API開発をするなら便利です。HTTPリクエストをシンプルかつ柔軟に送れるGUIツールです。

zsh

bashから変えたい方はここで変える。ちなみに、Macアカウントのユーザー名などもあとから変えるのは破壊的な変更があって手間なので、今のうちに変えておくとよいかも。

zshよりgshellおすすめだよと社内の強いエンジニアの方に言われたので、そちらもいいかも?(ただ、ググっても情報は少なかった。))

ID・アカウントの作成

Googleアカウント

新しいものがほしければ作りましょう。

Appleアカウント

こちらも新しいものがほしければ作りましょう。アプリをインストールする上で必要になったりします。

おわりに

思ったより少ないですね。あとは、ふだん使う言語に応じてローカルに環境を作ったり、ってところでしょうか。

他によいものがあれば、ぜひ教えてください。

おわり。

SPAのTwitterログイン認証(OAuth)

SPA(RailsAPI + Vue.js)でのTwiiterログインを実装したので、自分の実装方法をまとめておきます。

概要図(ステップ)

f:id:fujiten3:20190801111818p:plain

実装の概要

まずはじめに、 実装を完走した感想ですがSPAで外部のAPIを利用したログインは基本的に辛みが深いように感じました。

自分の場合は、クライアント側(Vue.js) → バックエンド側(RailsAPI) → 外部API(TwitterAPI) というリクエストの遷移を基本にして実装したわけですが、「バックエンド → 外部API」の結果手に入れた認証情報をクライアント側に返す処理をどのように行うかというところで参考になるサイトがとても少なかったことでなかなか苦戦を強いられたからです。

調べたところクッキーを利用する以外にズバッと解決できそうなものを見つけられなかったので、今回はクッキーを使っています。

つまり、「バックエンド側(RailsAPI) → 外部API(TwitterAPI)」のやり取りの結果の一部をクッキーでクライアント側に返し、クライアント側でそれを受け取ったあと、そのクッキー情報に基づいてクライアントが再びRailsAPIを叩き、完全なログインを実現するという流れです。

ベストプラクティスかどうかはわかりませんが、XSS対策への対応は出来ています。

ログイン認証の大本は、以下の記事通りに作成しています。

fujiten3.hatenablog.com

ステップ1

(簡略化してポイントだけ書いてます。)

VueからRailsAPIを叩き、ステップ5を行うためのコールバックURLを貰う。

signinWithTwitter () {
  this.$http.secured.get('/authenticate')
    .then(response => location.href = response.data.oauth_url )
    .catch(error => this.signinFailed(error))

location.href = response.data.oauth_urlで、認可のためのコールバックURLを貰って、Twitterの承認画面に切り替えるという挙動になります。

ステップ2〜5

コールバックURLを返すための処理です。

また、クライアントサイドに「リクエストークン」をクッキーで返しています。(アクセストークンの取得に必要)

  def authenticate

    consumer ||= OAuth::Consumer.new(
      ENV['TWITTER_CONSUMER_KEY'],
      ENV['TWITTER_CONSUMER_KEY_SECRET'],
      { :site => "https://api.twitter.com" }
    )

    request_token = consumer.get_request_token(
      oauth_callback: ENV['TWITTER_CALLBACK_URL']
    )

    response.set_cookie("request_token",
      value: request_token.token,
      httponly: true,
      secure: Rails.env.production?)

    response.set_cookie("request_token_secret",
      value: request_token.secret,
      httponly: true,
      secure: Rails.env.production?)

    rtn = {}
    rtn["status"] = 'return_callback_url'
    rtn["oauth_url"] = request_token.authorize_url

    render json: rtn
  end

ステップ6〜7

認証に成功した場合、アクセストークンをクッキーで返答します。

def twitter

  request_token = OAuth::RequestToken.new(
    consumer,
    request.cookies['request_token'],
    request.cookies['request_token_secret']
  )

  access_token = request_token.get_access_token(
    {},
    :oauth_token => params[:oauth_token],
    :oauth_verifier => params[:oauth_verifier]
  )

  twitter_response = consumer.request(
    :get,
    '/1.1/account/verify_credentials.json',
    access_token, { :scheme => :query_string }
  )

  case twitter_response
  when Net::HTTPSuccess
    user_info = JSON.parse(twitter_response.body)

    if user_info["screen_name"]

      user = User.find_or_create(user_info, 'twitter')

      if user.persisted?

        payload = { user_id: user.id }
        session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
        tokens = session.login
        cookie_key_value_pairs = {

          'oauth_token2' => access_token.params["oauth_token"],
          'oauth_token_secret' => access_token.params["oauth_token_secret"],
          'csrf' => tokens[:csrf],
          'ac_token' => tokens[:access]
        }

        set_cookies_at_once(response, cookie_key_value_pairs)

        response.set_cookie('signedIn',
        value: true,
        domain: ENV["BASE_DOMAIN"],
        path: "/",
        secure: Rails.env.production?)

        set_cookie_at_token_validness(true)

        redirect_to ENV['CLIANT_SIDE_TOP_PAGE']

      else

        redirect_to ENV['CLIANT_SIDE_TOP_PAGE']

      end

    else
      # 認証失敗
    end

  else
    # OAuthを通してユーザ情報取得失敗
  end

end

ステップ8

クライアントにリダイレクトした後、実行されるメソッドです。

クッキーのsignedInに値が入っていた場合、RailsAPIを叩きます。

    checkSignedInWithCookie () {
      const cookies = document.cookie.split('; ')
      let obj = {}
      cookies.forEach(cookie => {
        let arr = cookie.split('=')
        obj[arr[0]] = arr[1]
      })
      if (obj.signedIn) {
        this.$http.secured.get(`/api/v1/users/show_me`)
          .then(response => {
            localStorage.signedIn = true
            localStorage.csrf = response.data.csrf

            this.$router.replace('/')
          })
          .catch(error => {
            this.setError(error, 'ログインエラー: なにかがおかしいです。')
          })
      }

ステップ9

tokenをAuthorizionヘッダーに格納したのち、 authorize_access_request!メソッドで、ステップ6〜7で発行したアクセストークンかどうかの認証を行っています。

認証に成功すれば、必要なユーザー情報やCSRFトークンを返答します。

def show_me
  ac_token = request.cookies['ac_token']
  request.headers['Authorization'] = "Bearer #{ac_token}"
  authorize_access_request!

  response.set_cookie('ac_token',
  value: nil,
  httponly: true,
  domain: ENV["BASE_DOMAIN"],
  path: "/",
  secure: Rails.env.production?)

  response.set_cookie('signedIn',
  value: nil,
  domain: ENV["BASE_DOMAIN"],
  path: "/",
  secure: Rails.env.production?)

  response.set_cookie(JWTSessions.access_cookie,
  value: request.cookies['ac_token'],
  httponly: true,
  secure: Rails.env.production?) 

  render json: { csrf: request.cookies['csrf'],
                  my_avatar: current_user.avatar.encode(:icon), 
                  uid: current_user.id  }
end

感想

ほとんど全ての記述をコントローラに書いてしまっているので、できればモデル層にコードを移したいなと思っています。

認証のためのクラスを作って、そこに処理を任せる形が一番きれいにリファクタリングできそうかな…。

SPAでのログイン認証とCSRF対策の実装(JWT使用)

SPA(Vue + RailsAPI)で何とかログイン認証機能 + CSRF対策を実装したので、ブログにメモしておきます。

実装の概要

今回は、JWT + (WebStorage + Cookie)を使って実装しました。(後に用語説明します)

WebStorageとJWTによるセッションの管理(ログイン状態の管理)は、セキュリティ的な難点を指摘する記事がちらほらありましたが、クッキーも利用して「アクセストークン」と「リフレッシュトークン」の2つを利用するライブラリを採用し、XSS脆弱性のリスクを下げました。あとはそのライブラリにCSRFトークンも発行も担保してもらうことで、CSRF脆弱性にも対応しています。

ただし、この方法がSPAの認証として確実にベターなのかは定かではありません。「あくまでもこういう実装があるよ」という風に捉えていただければ幸いです。

あと、今回は「リフレッシュトークン」自体は発行せず、「アクセストークン」にその機能をもたせた形で実装しています。

基本的に、既存のライブラリの教えに従った実装なので、詳しくはそちらを参考にどうぞ。

JWTSession: GitHub - tuwukee/jwt_sessions: XSS/CSRF safe JWT auth designed for SPA

自分が詰まったところをメインで文章にしておこうと思います。

使用した技術たち

JWT(JsonWebToken)

改ざんされず、URLSafe(URLに含めることができる文字列のみで構成されている)JSON形式のトークンです。

Base64エンコードされているだけなので、鍵情報なしに復号可能です。

# JWTをデコードすると…
Base64.decode64('"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1NjI5NDUzMzAsInVzZXJfaWQiOjczLCJ1aWQiOiI1M2U4ODdlMy0zYTE4LTQyNzgtYTU0MS1jMDMwNzA4OTUwMDEiLCJleHAiOjE1NjI5NDUzMzAsInJ1aWQiOiI4Yjg3MjFlYS1kZGNmLTQ3NTgtYTUwYi1iMDk0ZDhkODU5MWIifQ._ygcLPv8UiGAX3oNlomGmOKFfRQYDJZRjZiAYPfDSrk"')

# algで、改ざん防止のための署名を暗号化するアルゴリズムを指定します。あとはユーザー情報だったり有効期限だったりも内包してます。
=> "{\"alg\":\"HS256\"}{\"exp\":1562945330,\"user_id\":73,\"uid\":\"53e887e3-3a18-4278-a541-c03070895001\",\"exp\":1562945330,\"ruid\":\"8b8721ea-ddcf-4758-a50b-b094d8d8591b\"}\f\xA0p\xB3\xEF\xF1H\x86\x01}\xE86Z&\x1Ac\x8A\x15\xF4P`2YF6b\x01\x83\xDF\r*\xE4"

参考記事: JWT(JSON Web Token)の仕組みと使い方まとめ │ Web備忘録

アクセストークン、リフレッシュトークンって?

セッション機能を担保するためのJWTタイプのトークンです。アクセストークンは短時間で期限が切れるトークンで、リフレッシュトークンはその期限の切れたアクセストークンのかわりに新しいアクセストークンを発行するためのトークンです。当然ながらリフレッシュトークンの方が期限が長いです。どちらも、トークン自体にデータを内包しています。

WebStorage

ブラウザのデータストアです。4MBまで保存できるのが魅力です。Chromeデベロッパーツールでいう「Applicationタブ」で確認できます。

SessionStorageとLocalStorageがありますが、今回はCSRFトークンを保存するために後者を使ってます。このCSRFトークンはJWTではありません。

JWTSession

トークン作成・管理を担ってくれるライブラリ。

GitHub - tuwukee/jwt_sessions: XSS/CSRF safe JWT auth designed for SPA

遷移の概要(より正確な内容は要Gem参照)

クライアント側でユーザーがパスワード等の認証情報を入力し、ログインのためのリクエストを送る。

バックエンド側がその内容で認証に成功したら、「HttpOnly(JSで操作不能)でSecure(HTTPs通信のみで取引)なクッキーとしてのアクセストークン」と、「POST、PUTリクエスト等で使うCSRFトークン(LocalStorageで保存)」をレスポンスする。アクセストークンは、「ユーザー情報」を内包している。

その受け取ったトークンたちをクライアントサイドで保存し、各リクエストに応じてそれらを送信することで、「ログインしているユーザー情報」をセッション管理しつつ、CSRFXSS対策を行っている。

トークンストアの設定

Redisを使っています。Docker上で運用していて、下記のようにdocker-compose.ymlファイルを作ってます。

version: '3.3'
services:
  app:
    build:
      context: .
    stdin_open: true
    tty: true
    ports:
      - '3000:3000'
    volumes:
      - .:/app
      - bundle_data:/usr/local/bundle
    depends_on:
      - db
      - redis
    environment:
      - REDIS_HOST=redis
      - REDIS_PORT=6379
  db:
    image: postgres:11.2
    ports:
      - '5432:5432'
    volumes:
      - postgresql_data:/var/lib/postgresql/data
  redis:
    image: redis:3.2.12-alpine
    ports:
    - 6379:6379
    volumes:
    - redis:/data
  
volumes:
  bundle_data:
    driver: local
  postgresql_data:
    driver: local
  redis:

今回使ったGem(JWTSession)は、環境変数にREDIS_URLをセットしておけばその場所をトークンストアとして使用します。

ローカルでは、REDIS_URL=redis://redis:6379、本番では実際のURLを指定しています。本番環境はherokuで運用してるのですが、REDISプラグインを導入すると自動で環境変数REDIS_URLをセットした気がします。(しなかった場合は、自分でセットしましょう)

JWT暗号化のアルゴリズムのための種は環境変数でセットしておきましょう。

(config/initializers/jwt_sessions.rb)

if Rails.env.production?
  JWTSessions.encryption_key = ENV['JWT_SESSION_KEY']
else
  JWTSessions.encryption_key = 'secret'
end

バックエンド側の仕事

トークン発行と管理を行います。

Signinコントローラー

以下は、説明コメント付きのSigninコントローラーです。ここで認証を行います。

class SigninController < ApplicationController
  # リクエストで送られたトークンの内容の検証し、検証失敗で例外を出す。(only: destroy(セッション破棄時))
  before_action :authorize_access_request!, only: [:destroy]

  # create は ユーザー作成ではなく、「トークン作成(セッション作成)」
  def create
    user = User.find_by(email: params[:email])
    if user&.authenticate(params[:password])
      # payloadは、トークン自体に内包させられるユーザー情報。ここではuser_idを内包させている。
      payload = { user_id: user.id }
      session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
      tokens = session.login

      # このメソッドで、「Set-Cookieヘッダー」に、{jwt_access=アクセストークン; Secure; HttpOnly}をセットし、クライアントに送る。
      response.set_cookie(JWTSessions.access_cookie,
                        value: tokens[:access],
                        httponly: true,
                        # 開発環境ではHTTPs通信ではないので、secure: falseになるようにしないと、クライアントがクッキーを受け取れない
                        secure: Rails.env.production?)
      # LocalStorageに保存するためのcsrfトークンを返しておく。
      render json: { csrf: tokens[:csrf] }
    else
      not_found
    end
  end

  def destroy
    session = JWTSessions::Session.new(payload: payload)
    session.flush_by_access_payload
    render json: :ok
  end

  private

    def not_found
      render json: { error: "メールアドレスとパスワードの組み合わせが見つかりません"}, status: :not_found
    end
    
end

フロントエンド側の仕事

アクセストークンに関してはクッキーに保存しているので、とくに設定を行わなくてもバックエンド側と自動でやり取りします。(クッキーはリクエストで常に送信されるため。)

CSRFトークンの使用に関しては、明示的に設定を記述する必要があります。

axiosから作ったインスタンス(非同期通信時に使用する)に、その設定を書き、POSTやPUTリクエストのさい、CSRFトークンを遅れるように設定しておきましょう!

axiosのインスタンスの作成

(axiosのインスタンスを作成し、exportしたものをグローバルで使う。)

import axios from 'axios'

// インスタンスを作る
const securedAxiosInstance = axios.create({
  baseURL: process.env.VUE_APP_API_URL,
  // クッキーといった認証情報を送るためにCredentialsはtrueにする
  withCredentials: true,
  headers: {
  // もちろん送信するのはJSON
    'Content-Type': 'application/json'
  }
})

// リクエストのとき、動的にヘッダーにCSRFトークンをセットする
securedAxiosInstance.interceptors.request.use(config => {
  const method = config.method.toUpperCase()
  if (method !== 'OPTIONS' && method !== 'GET') {
    config.headers = {
      ...config.headers,
      // localStorageの値をセット!
      'X-CSRF-TOKEN': localStorage.csrf
    }
  }
  return config
})


// レスポンスで401エラーが帰ってきたとき、トークンの再発行を依頼する。
securedAxiosInstance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (error.response && error.response.config && error.response.status === 401) {
      return plainAxiosInstance.post('/refresh', {}, { headers: { 'X-CSRF-TOKEN': localStorage.csrf } })
        .then(response => {
          localStorage.csrf = response.data.csrf
          localStorage.signedIn = true

          let retryConfig = error.response.config
          retryConfig.headers['X-CSRF-TOKEN'] = localStorage.csrf
          return plainAxiosInstance.request(retryConfig)
        }).catch(error => {
          console.log('failed refresh')
          delete localStorage.csrf
          delete localStorage.signedIn

          location.replace('/signin')
          return Promise.reject(error)
        })
    } else {
      return Promise.reject(error)
    }
  }
)

// exportしておこう
export { securedAxiosInstance }

main.jsでインスタンスの読み込み

main.jsで、そのインスタンスを読み込み、どのコンポーネントでも使用できるようグローバルにする。

Vue.use(VueAxios, {
  secured: securedAxiosInstance,
})

インスタンスの使用方法

signin () {
  this.$http.secured.post('/signin', {email: this.email, password: this.password})
    .then(response => this.signinSuccesful(response))
    .catch(error => this.signinFailed(error))
}

これでフロント側は大丈夫です。

ただ、ブラウザによって上手く動作しないものが…

ただ、こうやっても自分の場合、本番環境のSafariiOS版のChromeでログイン認証が上手くいかない、といった苦労がありました。

JWTSessionについてググりまくったり、ActionDispatchの挙動について調べたりして原因を探ったところ、どうやら、先に挙げたブラウザは、バックエンドが返したレスポンスに含まれる「「Set-Cookieヘッダー」の {jwt_access=アクセストークン; Secure; HttpOnly} を読み込めておらず、ブラウザにクッキーが保存できていない、ということがわかりました。

(サラッと書きましたがこの結論に辿り着くまで結構かかりました(涙目))

そしてクッキーが保存できない原因は、PC版Chrome以外は「3rd party cookiesを受け取らない」という設定がデフォルトであった、ということであると判明しました。

3rd party cookiesとは?

3rd party cookiesというのは、そのサイトの運営者以外のドメインが送信するクッキーのことです。たとえば誰かのブログを見てたりすると、「身につけるだけで腹筋が割れるベルトが新発売」みたいな広告が右端に表示されてることがありますが、そういう第三者の広告がブラウザにクッキーを保存することを防ぐために、PC版Chrome以外は3rd party cookieの受け取りを禁止しているようでした。

(ChromeGoogle Analyticsなどを使用して、ドメインに基づかずとも、そういうクッキーを弾いているみたいです。)

原因がわかったので、VueアプリとRailsAPIのドメインを共通化すると解決しました。(domain.comとwww.domain.comに、それぞれアプリを配置し、メインドメインサブドメインの関係にした)

余談

かなり難しかった…。でも、おかげで知識量は増えた気がします。

ただ、今回のクッキーに関するエラーって、先に「3rd party cookieの取扱い」に知っていれば、もっと早く原因わかったかもな、と思いました。

自分の場合、そもそもこういうエラーを前にして初めてそういう「3rd party cookie」概念があることを知ったのですが、そういった風に「実際にエラーが出てからそのエラーの根本原因について調べる」ような実装をしていると、めちゃくちゃ時間吸われるんですよね。(検索ワードが「クッキー 受け取れない safari」とか「set-cokkie not working」とかぼんやりしたものになって、なかなか欲しい情報にたどり着けない)

もちろん、そのおかげで色々知識は身につくのですが、回り道してる感も否めないので、広く浅く知識の網は広げておいて、エラーが起きたときにすぐ適切なワードでググれるようになれば、時間の節約になりそうです。

Vueでパスワードの強度を動的に判定できる送信フォームを作りました。

タイトルの通り、Vueでパスワードの強度をクライアントサイドで判定しながら送信できるフォームを作りました。

f:id:fujiten3:20190711142645g:plain
PasswordStorengthChecker

JSFiddle上に置いておいたので、欲しい方はどうぞ。編集自由ですが、ブログ等での再配布はご遠慮ください。

jsfiddle.net

挙動については本当にgifで見てもらった通りなのですが、4つの条件(文字数、小文字、数字、大文字)を満たしたパスワードのみ送信を行うというものです。

多すぎる条件はユーザー体験を損ないかねないので、4つも条件が必要かは微妙なところですが、セキュリティに関するところというところもあり、判定が甘すぎるよりはいいと思います。

以下、コードの解説です。Vueに興味のない人は最後の余談だけでもよければ御覧ください。(大したことは話してません)

HTML側

<div id="app">
  <div class="container">

    <div class="input_container">
      <label for="name" class="label">パスワード</label>
      <input type="password" @input="onCheckedBeValid" v-model="password" placeholder="6文字以上" />
      <span v-bind:class="{ valid_password_length: validPasswordLength }" class="password_length">{{passwordLength}}</span>
    </div>
    
    <div class="validation_container">
      <p v-bind:class="{ lowercase_valid: containsLowercase }">小文字</p>
      <p v-bind:class="{ number_valid: containsNumber }">数字</p>
      <p v-bind:class="{ uppercase_valid: containsUppercase }">大文字</p>
    </div>

    <div class="button_container">
      <button @click="signUp">登録</button>
      <p>{{ message }}</p>
    </div>

  </div>
</div>

inputタグについて

<input type="password" @input="onCheckedBeValid" v-model="password" placeholder="6文字以上" />

@inputで「inputイベント」が起こるたびに着火されるメソッドを指定し、入力された値(value)については、v-model="password"を使って、双方向バインディングしています。

spanタグについて

<span v-bind:class="{ valid_password_length: validPasswordLength }" class="password_length">{{passwordLength}}</span>

属性の値を動的にセットできるv-bindを使い、今回はclass属性の値を動的にセットしています。

「v-bind:class="{ valid_password_length: validPasswordLength }"」というのは、validPasswordLengthがtrueのとき、valid_password_lengthというクラスをこのタグにセットするという意味になります。これを使い、文字数が条件を満たしたときだけに、動的にクラス名をセットして、付与されるスタイルを変更しています。

JS(Vue)側

new Vue({
  el: "#app",
  data () {
    return {
      password: '',
      message: '',
      passwordLength: 0,
      containsLowercase: false,
      containsNumber: false,
      containsUppercase: false,
      validPasswordLength: false,
      isValidPassword: false
    }
  },
  methods: {
    onCheckedBeValid () {
      this.passwordLength = this.password.length

      this.passwordLength > 5 ? this.validPasswordLength = true : this.validPasswordLength = false
      this.containsLowercase = /[a-z]/.test(this.password)
      this.containsNumber = /\d/.test(this.password)
      this.containsUppercase = /[A-Z]/.test(this.password)
    },
    checkAllValidations () {
      if (this.containsLowercase 
          && this.containsNumber 
          && this.containsUppercase
          && this.validPasswordLength) {
        this.isValidPassword = true 
      } else {
        this.isValidPassword = false
      }
    },
    signUp () {
      this.checkAllValidations()
      this.isValidPassword ? this.message = '送信しました' : this.message = 'パスワードに問題があります'
    }
  }
})

メソッドの簡単な説明を残しておきます。

onCheckedBeValidメソッド

Inputイベントで着火するメソッドで、入力されたパスワードが先に述べた4つの条件を満たすかどうかそれぞれ判定します。ここでtrueになった条件たちは、HTML側で:classにバインドされているので、それぞれスタイル付与に貢献します。

checkAllValidationsメソッド

メソッド名通り、全てのバリデーションをチェックします。

signUpメソッド

ここにバックエンドにアクセスするための非同期通信の処理を書きます。axios等を使いましょう。ここでは記述を割愛しています。

CSS

.container {
  margin: 30px auto;
  height: auto;
}

.input_container, .validation_container, .button_container {
  display: block;
  margin: 0 auto;
  height: auto;
  display: flex;
  justify-content: center;
}

.password_length {
  padding: 2px 10px;
  margin-left: 120px; 
  background: rgb(236, 57, 57);
  color: white;
  border-radius: 10px;
  font-size: 13px;
  transition: all .1s;
  position: absolute;
}

.valid_password_length {
  background: green;
}

.validation_container p {
  width: 80px;
  margin: 5px;
  height: auto;
  font-size: 12px;
  text-align: center;
  border-radius: 2px;
  color: rgba(71, 87, 98, .8);
  background: linear-gradient(to right, green 50%, #eee 50%);
  background-color: #eee;
  background-size: 200% 100%;
  background-position: right;
  transition: background .3s;
}

.lowercase_valid,
.number_valid,
.uppercase_valid {
  background-position: left !important;
  color: white !important;
}

p {
  margin: 0;
}

一番のポイントは緑色のバーが動的に動くところだと思うので、そこの部分だけ抜粋します。

 .validation_container p {
  background: linear-gradient(to right, green 50%, #eee 50%);
  background-color: #eee;
  background-size: 200% 100%;
  background-position: right;
  transition: background .3s;
}

.lowercase_valid,
.number_valid,
.uppercase_valid {
  background-position: left !important;
  color: white !important;
}

こちらがその部分です。文章だけで解説しますが、まず、2行目のbackground: linear-gradient(to right, green 50%, #eee 50%);で「左50%が緑、右側50%が白色の背景」をセットしています。そして、background-position: right;で、「その背景の右側(白色)を自分のポジション」として固定します。

次に、「パスワードが条件を満たしたときのクラスの追加」で、background-position: left !important;というスタイルを付与することで、今まで右側(白色)にいた自分が、左側(緑色)に移ります。その移動をトランジション要素で動的に行うことで、白色と緑色の境界線が、まるでバーの動きのように表現されているというわけです。

コンポーネント・モジュール化

新規登録画面が2ページ以上ある場合は、このフォームをコンポーネント化しておいた方がいいと思われますが、2ページ以上あるサイトはあまりないと思われますので、必要になるまでは共通化しなくてもいい気がします。自分は共通化せずにそのまま使ってます。

「条件を満たすと満タンになるバー」は、他の部分で再利用できそうなので、そこはコンポーネントにしてもいいかもしれまん。

余談

4つの条件をわざわざ満たさせるフォームはユーザにとって煩わしさはあるかもしれませんが、認証情報はサイトの核なので、特に「決済システム」などの重大な機能をもつサイトを運用する場合は、このようなフォームでパスワード強度を動的に確認してあげるとよいと思います。

バックエンドに渡す前にバリデーションにかけれるので、無駄な通信も防げてエコですしね。(だいぶ微々たるものでしょうけど)

おしまい。

Vueで画像アップロード + プレビュー機能付きフォームを作りました。(Base64エンコード利用)

タイトルの通り、Vueで簡単な画像アップロードページを作ったので、JSFiddleで公開してみます。

今回はBase64エンコードを使って、画像を文字列情報として扱う方法を採用しています。

バックエンドとのやり取りをJSONベースで行っている場合は、この方法がシンプルで扱いやすいと思います。

VueImageForm - JSFiddle - Code Playground

挙動については上のリンクに飛んで触っていただければすぐにわかると思います。簡易的な画像アップロード機能と、画像送信前のプレビュー機能のついたフォームです。

ほしい方は勝手に使っていただいて大丈夫です。編集も自由です。変な所あったら勝手に直しちゃって下さい。(そしてこっそり教えて下さい。)

「なんでBase64エンコード使ったん?」という件については、一番最後の余談で書いたので、Vue興味ない方はそこまで飛ばして下さい!!

一応、それぞれのコードの解説を書いておきます。

HTML側

<div id="app">
  <p id="error" v-show="error">{{ error }}</p>
  <label>
    <p>クリックで画像を変更できます。</p>
    <img :src="avatar" alt="Avatar" class="image">
    <div>
    <input
           type="file"
           id="avatar_name"
           accept="image/jpeg, image/png"
           @change="onImageChange"
           />
    </div>
  </label>
  <button @click="upload()">アップロード</button>
  <p>{{ message }}</p>
</div>

labelタグについて

これでimgタグとinputタグを囲めば、inputタグのクリック可能範囲を広げ、画像クリックに判定が付きます。

imgタグについて

:src="avatar"で、Vue側がもつdataの「avatar」をバインドしてきています。Vue側のavatarの値が変更されれば、src属性の値は自動で更新されます。

avatarのデータ型はStringで、画像をBase64エンコードしたものを渡しています。Base64形式でsrc属性に値を渡せば、特別な処理を書かずとも画像を表示できます。

Base64エンコードとは?

データを文字列として表す変換方式です。メールの添付等でよく使われるようです。

inputタグについて

@change="onImageChange"は、inputでファイルが変更されるたびに、onImageChangeメソッドが実行されるという意味です。

ここは「v-model="avatar"」でも動きそうに見えますが、inputのtype="file"はvalueをもつことが出来ないので、v-modelでavatarをセットしても、その値をinputタグが保持しておくことができずにエラーが出ます。

JavaScript(Vue)側

new Vue({
  el: "#app",
  name: 'ImageUploder',
  data () {
    return {
      avatar: '',
      message: '',
      error: ''
    }
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    getBase64 (file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.readAsDataURL(file)
        reader.onload = () => resolve(reader.result)
        reader.onerror = error => reject(error)
      })
    },
    onImageChange (e) {
      const images = e.target.files || e.dataTransfer.files
      this.getBase64(images[0])
        .then(image => this.avatar = image)
        .catch(error => this.setError(error, '画像のアップロードに失敗しました。'))
    },
    upload () {
        if (this.avatar) {
        /* postで画像を送る処理をここに書く */
        this.message = 'アップロードしました' 
        this.error = ''
      } else {
        this.error = '画像がありません'
      }
    }
  }
})

各メソッドについて、簡単に解説します。

getBase64メソッドについて

getBase64 (file) {
  /* Promiseにアロー関数を渡して非同期処理を書く。*/
  return new Promise((resolve, reject) => {
    
    /* 組み込みオブジェクトを使い、Base64エンコードされた画像データを取得  */
    const reader = new FileReader()
    reader.readAsDataURL(file)

    /* 成功時と失敗時の処理。resolveの引数に結果を渡して、のちの.then(result => ...)で受け取る  */
    reader.onload = () => resolve(reader.result)
    reader.onerror = error => reject(error)
  })
}

onImageChangeメソッドについて

inputでファイルが入力されると着火されるメソッドです。イベントオブジェクトからファイルを貰い、そのもらったファイルをBase64エンコードしたものを、avatarにセットしています。これでavatarの値が変更されるのですが、そのとき、その変更を検出したimgタグがsrc属性に新たなavatarの値を当てはめます。すると、選択したファイルの画像が表示されます。これがプレビュー機能に当たる部分です。

uploadメソッド

バックエンドにBase64エンコードされた画像を送る処理を書くところです。今回は詳しい処理については割愛していますが、axiosでバックエンド側のAPIにpostまたはpatchする処理を書きましょう。

CSS

input {
  display: none;
}

img:hover {
  opacity: 0.7;
}

#error {
  color: red;
}

特筆すべき点はないですが、imgにhoverしたら透明度を上げるようにしてクリック判定をわかりやすくしてます。

コンポーネント・モジュール可する

せっかく作ったフォームなので、コンポーネントにして取扱いを良くしたり、他で使いそうなメソッドはモジュール化した方がいいかもしれません。(設計に関する一番むずかしいところ…)

とりあえず自分は、以下のように親子関係を作ってみました。

親側のフォーム

<label>
  <img class="w-24 h-24 rounded-full mr-4 bg-hover" v-lazy="avatar" alt="Avatar">
  <UserImageUploader
    v-bind="user"
    :params="{ limit: 1000, unit: 'kb', allow: 'jpg,png' }"
    v-model="avatar"
   />
</label>

3行目が子コンポーネントです。(UserImageUploader)。親側で呼び出すときに必要なデータをバインドして子供側で扱えるようにしてます。

今回の場合だと、ユーザー情報、バリデーション情報、画像情報を渡してます。

子供側(コンポーネント名:UserImageUploader)

<template>
  <div>
    <p id="error" v-show="error">{{ error }}</p>
      <div class="m-6">
        <input
          type="file"
          id="avatar_name"
          class="input_image"
          accept="image/jpeg, image/png"
          @change="onImageChange"
        />
      </div>
  </div>
</template>

<script>
import { FileEvaluable } from '@/components/mixins/FileEvaluable'
export default {
  name: 'UserImageUploder',
  mixins: [ FileEvaluable ],
  props: {
    id: Number,
    currentImage: String
  },
  model: {
    // このcurrentImageは親で指定した(v-model="avatar")と同値。
    prop: 'currentImage',
    event: 'change'
  },
  data () {
    return {
      message: '',
      error: ''
    }
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    getBase64 (file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.readAsDataURL(file)
        reader.onload = () => resolve(reader.result)
        reader.onerror = error => reject(error)
      })
    },
    setImage (currentImage) {
      this.$emit('change', currentImage)
    },
    onImageChange (e) {
      const images = e.target.files || e.dataTransfer.files
      if (this.validation(images[0])) {
        this.getBase64(images[0])
          .then(image => this.setImage(image))
          .catch(error => this.setError(error, '画像のアップロードに失敗しました。'))
      } else {
      }
    },
    validation (file) {
      if (!this.isAllowFileType(file.type)) {
        this.error = this.getErrorMessageType()
        this.canBeUploaded = false
        return false
      }
      if (!this.isAllowFileSize(file.size)) {
        this.error = this.getErrorMessageSize()
        this.canBeUploaded = false
        return false
      }
      this.error = ''
      this.canBeUploaded = true
      return true
    }
  }
}
</script>

<style scoped>
.input_image {
  display: none;
}
</style>

読みづらいですが、propだったりmodelだったりで、親からバインドされた値を受け取ってます。

子供側でmixinsされてるモジュールは、今回の記事とは関係ないバリデーション処理のものですが、わざわざ取り除くのも大変だったのでべた張りしました。

機能としての改善点としては、プレビューの画像サイズが今のままだとユーザー依存なのでそれを変更したり、選択した画像の一部分だけ使うよう編集できる機能についても必要かな、と思いますが、長くなりますので今回はここまでとします。

余談

画像の送信方法について、最初はBase64エンコードを使わず、JSにおけるFormData型の画像を「Content-Type: multipart/form-data」でバックエンドに送る方式をとっていました。ただ、自分がバックエンドで使用しているRailsAPIとのかみ合わせが悪く、リクエストではファイルをそのまま受け取れるものの、レスポンスにおいてはJSONじゃないファイル型を返せるコントローラーを新たに作ったりする必要がある(ActionController::APIを継承したコントローラじゃファイルをrender出来ない。)というデメリットがあり、一度実装してみたものの、取り回しが悪そうなので削除しました。

(こういうの実装する前に気づけるようになりてえ〜)

ちなみに、Railsでは受け取ったエンコード済み画像をデコードしてFileオブジェクトに戻し、それをAWSのS3に保存してます。

その処理もまた後にブログにするかもしれません。(しないかもしれない! 読みたい方がいたら書きます)

おしまい。

続編

fujiten3.hatenablog.com

fujiten3.hatenablog.com