L08084のブログ

技術記事の執筆は、祈りに近い

Express(Node.js) + SQLite でパパッとブログを作る

パパッとは作れてないです。

はじめに

Express(JavaScriptベースのサーバサイドフレームワーク)に入門したくなったので、簡単なブログを作ってみます。Node.jsとnpmはインストールされている前提で進めていくので、インストールされてない方は公式サイトでインストールしてください。

なお、完成版のソースコードは、GitHubに置いています。

github.com

バージョン情報

  • npm: 5.6.0
  • Node.js: 8.11.3
  • Express: 4.16.0

エディタは Visual Studio Code を使用

1.アプリのひな形を作る

Expressアプリケーションのひな形を作成してくれるライブラリであるExpress Generator を使って、アプリの基本部分を作成していきます。

# Express Generatorをグローバルインストール
npm i -g express-generator

Express Generator のインストールが完了したら、下記のコマンドでアプリを作成します。

# ex-blogのところには、アプリ名を入れてください
express -e ex-blog

-eというオプションがついていますが、これは「テンプレートエンジンにEJSを使用する」という意味です。-eを省略すると、テンプレートエンジンにJadeが採用された状態でアプリケーションが作成されます。

下記コマンドを実行すると、アプリが起動するので、ブラウザを立ち上げて、http://localhost:3000/を入力してください。「Express」とブラウザに表示されれば、雛形の作成は完了です。

cd ex-blog
npm i
# アプリ起動
npm start

2. SQLiteのインストール

データベースについては、環境構築とか設定が楽そうなのでSQLiteを使います。公式サイトに行って環境(OSとかの)にあったやつをダウンロードしてください。

3. SQLiteのGUIツールもインストール

GUIツールもついでに落としましょう。OSにあったインストーラをダウンロードしてください。DB Browser for SQLite

4. データベースを設計する

f:id:l08084:20180707205211p:plain

DB Browser for SQLite を起動して「New Database」ボタンをクリックすると、データベースファイルの保存ダイアログが現れるので、mydb.sqlite3という名称で、ex-blogフォルダの配下に新規作成してください。

f:id:l08084:20180707205703p:plain

データベースファイルを作成したら、投稿したブログを保存する用のテーブルを作成しましょう。設定については、下記の画像を参考にしてください。テーブル名をpostに設定しています。

f:id:l08084:20180707210429p:plain

上記の画像では、idのTypeをINTEGERに設定して右側に見えるチェックボックスをすべてONにしていますが、こうすることでidがオートインクリメントの主キーになります。

f:id:l08084:20180707211058p:plain

注意点ですが、テーブルの作成に限らず、データベースの設定が終わったら「Write Changes」ボタンを押すのを忘れないようにしてください。押さないと変更が適用されません。

最後にターミナルでアプリケーションフォルダ(ex-blog)をカレントディレクトリに設定して、SQLite3モジュールをインストールすると、ExpressからSQLiteにアクセスする準備が完了します。

npm i sqlite3

5.コードを書く

Express Generator が作成した雛形のプログラムファイル・テンプレートファイルを更新して、ブログを作っていきます。

5-1. 記事の投稿画面を作る

まず、views/write.ejsファイルを新規作成して、下記のように記入してください。このテンプレートファイルは、記事投稿画面として使用されます。

views/write.ejs

<!DOCTYPE html>
<html>
  <head>
    <title>Express Blog</title>
    <link rel='stylesheet' href='/stylesheets/style.css' />
  </head>
  <body>
      <h1>新しい記事を投稿</h1>
        <form action="/write" method="POST">
            <table>
                <tr>
                    <!-- 記事のタイトルを入力する欄 -->
                    <th>Title</th>
                    <td>
                        <input type="text" class="title-input" name="title" />
                    </td>
                </tr>
                <tr>
                    <!-- 記事の内容を入力する欄 -->
                    <th>Content</th>
                    <td>
                        <textarea name="content"></textarea>
                    </td>
                </tr>
                <tr>
                    <!-- 記事の投稿ボタン -->
                    <th></th>
                    <td>
                        <button type="submit">投稿</button>
                    </td>
                </tr>
        </table>
        </form>
  </body>
</html>

続いて、記事投稿画面のルーティングスクリプト(投稿画面に遷移した時の処理)を作成していきます。routes/write.jsファイルを新規作成してください。

routes/write.js

var express = require('express');
var router = express.Router();
// sqlite3モジュールのインポート
var sqlite3 = require('sqlite3');

var db = new sqlite3.Database('mydb.sqlite3');
// /write にgetメソッドでリクエストすると、write.ejsをレンダリング
router.get('/', (req, res, next) => res.render('write'));

// /write にpostメソッドでリクエストした時の処理
router.post('/', (req, res, next) => {
    // 投稿画面のフォームからタイトルと本文を取得
    const title = req.body.title;
    const content = req.body.content;
    // 投稿日付を取得
    const createdtime = new Date();
    // 投稿記事をDBにinsertする
    db.run(
        'insert into post (title, content, createdtime) values (?, ?, ?)',
        title,
        content,
        createdtime
    );
    // ホーム画面(index.ejs)にリダイレクト
    res.redirect('/');
});

module.exports = router;

記事投稿画面へのルーティングを追加するために、app.jsにも修正を加えます。(add this!とコメントされている部分を追加すればOKです。)

app.js

var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

var indexRouter = require('./routes/index');
// add this!
// write.jsのロード
var writeRouter = require('./routes/write');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);
// add this!
// アドレスの割当
app.use('/write', writeRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

レイアウトもテキトーに整えましょう

public/stylesheets/style.css

body {
  padding: 50px;
  font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}

a {
  color: #00B7FF;
}

input[type="text"],
textarea {
    width: 400px;
    padding: 0.8em;
    outline: none;
    border: 1px solid #DDD;
    -webkit-border-radius: 3px;
    -moz-border-radius: 3px;
    border-radius: 3px;
    font-size: 16px;
}
textarea {
    width: 600px;
    height: 200px;
}

input[type="text"]:focus,
textarea:focus {
    box-shadow: 0 0 7px #3498db;
    border: 1px solid #3498db;
}

button {
  border:solid 1px #ccc;
  padding:15px 30px;
  margin: 20px 0;
  font-family:Arial, sans-serif;
  font-size:1.2em;
  text-transform:uppercase;
  font-weight:bold;
  color:#333;
  cursor:pointer;
}

この段階で、npm run startコマンドでアプリを起動した後に、http://localhost:3000/writeにブラウザでアクセスすると、こんな感じの画面が表示されるはずです。

f:id:l08084:20180708161039p:plain
記事投稿画面

5-2. 記事の閲覧画面(ホーム画面)を作る

続いて、投稿した記事を閲覧する画面の方も作っていきましょう。テンプレートファイルのviews/index.ejsを下記のように書き換えてください。

views/index.ejs

<!DOCTYPE html>
<html>
  <head>
    <title>Express Blog</title>
    <link rel='stylesheet' href='/stylesheets/style.css' />
  </head>
  <body>
    <h1>Express Blog</h1>
    <a href="/write">Write</a>
    <% for(var i=0; i<posts.length; i++) {%>
    <h2><%= posts[i].title%></h2>
    <p><%- posts[i].content%></p>
    <% } %>
  </body>
</html>

ポイントとしては、posts[i].contentの部分で、改行タグ<br>を出力するために<%= %>ではなく、エスケープなしで展開してくれる<%- %>を使っている部分に若干注意が必要です。

    <h2><%= posts[i].title%></h2>
    <p><%- posts[i].content%></p>

記事閲覧画面のルーティングスクリプトも書き換えます。DBからの記事一覧取得と改行コードを改行タグ<br>に変換する処理を行なっています。

routes/index.js

var express = require('express');
var router = express.Router();

var sqlite3 = require('sqlite3');

var db = new sqlite3.Database('mydb.sqlite3');

/* GET home page. */
router.get('/', (req, res, next) => {
  db.serialize(() => {
    // DBから投稿されたブログをすべて取得
    db.all('select * from post', (err, rows) => {
      if (!err && rows) {
        // 改行コードを<br>に変換
        const newRows = rows.map(row => {
          if (row.content) {
            row.content = row.content.replace(/\r?\n/g, '<br>');
          }
          return row;
        });
        console.log(newRows);
        // postsパラメータを渡した状態で、index.ejsをレンダリング
        res.render('index', { posts: newRows });
      }
    });
  });
});

module.exports = router;

6.動作確認

これでいったん完成です。npm run startコマンドの後にブラウザでhttp://localhost:3000/下記のような、ブログのホーム画面が表示されるはずです。

f:id:l08084:20180708172517p:plain
ホーム画面

記事の投稿画面から記事を投稿して、ホーム画面に記事が表示されるかなども確認してみてください。

f:id:l08084:20180708172841p:plain

つまづいたところ

  • アプリから、DBのpostテーブルを呼び出す処理でno such tableみたいなエラーが出まくる
    • テーブルを作った後に、DB Browser for SQLiteでWrite Changeボタンを押しておらず、変更が適用されていなかったのが原因だった

iOSのFace ID, Touch IDを使用したログイン機能を設計する - Part2

はじめに

以前、iOSのFace ID, Touch IDを使用したログイン機能を設計する - l08084のブログ という記事を書いたんだけど、実装してて辛かったり、そもそもセキュリティ的にマズそうだったので、色々と設計を変えた。

前回の設計

前回した設計なんですが、だいたいこういう流れで、Face ID, Touch IDを実施するつもりでした。

初回ログイン

  1. UDIDを発行して、iOSデバイスのキーチェーンに格納
  2. ユーザーがログイン画面にメールアドレスとパスワードを入力してログイン
  3. この時、発行したUDIDもサーバーに送る
  4. 「UDID」と「メールアドレス・パスワード」を関連付けてDBに保存
  5. これで次回以降は、Face ID, Touch IDによる認証が有効になる

Face ID, Touch IDによるログイン

  1. Face ID or Touch IDを実行して成功する
  2. iOSデバイスのキーチェーンからUDIDを取り出す
  3. UDID+固定文字列をHMAC-SHA256形式でハッシュ化
  4. ハッシュと現在時刻を連結させたものを、AES256で暗号化、暗号化した結果をBASE64Encodeするこれをシグネチャとする
  5. シグネチャをサーバーに送る
  6. サーバー側でBASE64Decode、AES復号化、送られてきた現在時刻から10分以上経過していないことを確認する
  7. ハッシュが一致することを確認する
  8. UDIDが正しく登録されているか確認する
  9. 認証処置完了、認証トークンを発行する

前回の設計を実際に実装してみてキツかった点

サーバサイド(Java)で復号化するのがキツイ

フロントエンド(Angular/TypeScript)で暗号化するのは、crypt-jsを使うことで簡単にできたんだけど、サーバサイド(Spring/Java)で復号のコードを書くのが、量多くて辛い。(参考サイト

暗号化と復号化に使用する共通鍵について考慮してなかった

ハッシュ化したりAESで暗号化するには、共通鍵が必要になるわけだけど、共通鍵にどのような仕組みを使うか考えてなかった。固定のパスフレーズを共通鍵にすると、それ自体がセキュリティホールになるし、どうしよ...

前回の設計のセキュリティ的にまずい点

UDIDじゃなくて、UUIDだよね?

端末固有の番号であるUDID(Unique Device Identifier)については、Cordovaのプラグインを使用して、取得する予定だったけど、そもそもUDIDは、本アプリから以外でも入手可能な番号だから、認証で使うのはまずいよね。。。せめて、UUID(Universally Unique Identifier)にしよう...

前回の設計のおさらい

UUIDじゃなくて、UDIDを使っているのは単に自分の勘違いなので、無視するとしても、前回の設計では下記の点を念頭に置いていました。

  • ジェイルブレイク対策
    • Keychainにただメールアドレスとパスワードを保存する方式だと、Keychainのセキュリティが破られた時に不安なので、わざわざUUIDを暗号化してから、Keychainに入れる方式にしていた
  • 盗聴対策
    • 通信途中にデータを奪われても、暗号化されているから安心!

今回の設計

前回の設計は、セキュリティ的には優れていたにせよ、実装が大変なので、単純にiOSのKeychainにメールアドレスとパスワードを格納して、Face ID/Touch ID 認証が成功したら、メールアドレスとパスワードを投げる設計に変更した。
通常のログイン方式でも、盗聴対策してないわけだし、ジェイルブレイクについては、端末を持っているユーザの自己責任という考え方で、設計変更については落着した。

【JavaScript】Promise チェーンで、戻り値がPromiseのメソッドをつなぐ

戻り値がPromiseのメソッドを順番に実行し、前の処理の結果を次の処理で使いたいという場合があります。そんな時は、Promise チェーンを使いましょう。

バージョン情報

  • Angular: 5.0.1
  • TypeScript: 2.4.2

サンプルコード

下記のコードは、iOSのFace ID/Touch ID認証に関するコードになります。呼び出しているメソッドの戻り値が全てPromiseなので、Promise チェーンで繋げることができます。

.thenのコールバック処理の中で呼びだすPromiseが戻り値のメソッドをreturnしているところがポイント

// 戻り値がPromiseのメソッド
this.keychainTouchId.isAvailable()
    .then((res: any) => {
        console.log(res);
        // 戻り値がPromiseのメソッド
        return this.keychainTouchId.has(BioAuthService.KEY_A);
    }).then((res: any) => {
        console.log(res);
        // 戻り値がPromiseのメソッド
        return this.keychainTouchId
            .verify(BioAuthService.KEYCHAIN_KEY, `ロックを解除してください`);
    }).then((res: any) => {
        console.log(res);
        this.password = res;
        // 戻り値がPromiseのメソッド
        return this.storage.get(BioAuthService.KEY_B);
    }).then((res: any) => {
        console.log(res);
        this.userId = res;
        const params = {
            loginId: this.userId,
            password: this.password,
            deviceToken: null
        };
        this.action.login(params);
    }).catch((error: any) => {
        // catchは一つでよい
        console.error(error);
    });

Promiseの処理を順番に実行していく方法として、ネストしていくやり方もありますが、ネストが深くなっていくとコードが読みづらくなるので、コールバック地獄という言葉に代表されるように、アンチパターンであるとされています。

参考サイト

Promiseを使う - JavaScript | MDN

【Angular】お互いにDIしあっている(循環参照)と発生するエラー「Uncaught Error: Can't resolve all parameters for XXXService: (?, [object Object], [object Object]).」

バージョン情報

  • Angular: 5.0.1

エラー内容

AサービスクラスにBサービスクラスをDI、BサービスクラスにAサービスクラスをDI みたいなこと(循環参照状態)をしていた結果、タイトルのエラーが発生

Uncaught Error: Can't resolve all parameters for XXXService: (?, [object Object], [object Object]).
    at syntaxError (webpack-internal:///306:684)
    at CompileMetadataResolver._getDependenciesMetadata (webpack-internal:///306:15764)
    at CompileMetadataResolver._getTypeMetadata (webpack-internal:///306:15599)
    at CompileMetadataResolver._getInjectableMetadata (webpack-internal:///306:15579)
    at CompileMetadataResolver.getProviderMetadata (webpack-internal:///306:15939)
    at eval (webpack-internal:///306:15850)
    at Array.forEach (<anonymous>)
    at CompileMetadataResolver._getProvidersMetadata (webpack-internal:///306:15810)
    at CompileMetadataResolver.getNgModuleMetadata (webpack-internal:///306:15378)
    at CompileMetadataResolver.getNgModuleSummary (webpack-internal:///306:15216)

サービスクラスをもう一つ追加して、循環参照を解消した

Aサービスクラスから、いくつかメソッドをCサービスクラスに分離して、
Bサービスクラスには、Aサービスクラスでなく、CサービスクラスをDIするようにした(循環参照を解消)結果、解決した。

参考サイト

Can't resolve all parameters for User: (?, [object Object]). · Issue #34 · ionic-team/ionic2-starter-aws · GitHub

iOSのFace ID, Touch IDを使用したログイン機能を設計する

業務でFace ID, Touch IDを使ったログイン機能を実装することになったので色々と調べた。

作成予定のシステム

  • Ionic(JavaScript, Angular)で作成されたハイブリットモバイルアプリケーション
  • サーバーサイドはJava(Spring)
  • 通常のログインには、メールアドレスとパスワードを使用
  • Face ID, Touch IDによる認証が成功した時には、メールアドレス/パスワード の入力をスキップした状態でログインできる

Face ID, Touch IDに対する誤解について

Face ID, Touch IDによる認証が成功すると、認証した人を特定できるユニークなIDを発行してくれるイメージを勝手に持っていたが、そんな機能はなかった。Face ID, Touch ID のAPIが提供する情報は、あくまで認証が「成功したかどうか」のみでIDを発行したりはしない。

処理詳細

過去に類似の実装経験がある人に話を聞いた結果、下記のイメージで実装する予定。

初回ログイン

  1. UDIDを発行して、iOSデバイスのキーチェーンに格納
  2. ユーザーがログイン画面にメールアドレスとパスワードを入力してログイン
  3. この時、発行したUDIDもサーバーに送る
  4. 「UDID」と「メールアドレス・パスワード」を関連付けてDBに保存
  5. これで次回以降は、Face ID, Touch IDによる認証が有効になる

Face ID, Touch IDによるログイン

  1. Face ID or Touch IDを実行して成功する
  2. iOSデバイスのキーチェーンからUDIDを取り出す
  3. UDID+固定文字列をHMAC-SHA256形式でハッシュ化
  4. ハッシュと現在時刻を連結させたものを、AES256で暗号化、暗号化した結果をBASE64Encodeするこれをシグネチャとする
  5. シグネチャをサーバーに送る
  6. サーバー側でBASE64Decode、AES復号化、送られてきた現在時刻から10分以上経過していないことを確認する
  7. ハッシュが一致することを確認する
  8. UDIDが正しく登録されているか確認する
  9. 認証処置完了、認証トークンを発行する

Appleのレビューガイドライン

Appleのレビューガイドラインで、Face IDについての記載があったので転載する。

https://developer.apple.com/jp/app-store/review/guidelines/

2.5.13 顔認証でアカウントを認証するアプリケーションには、ARKitやその他の顔認証テクノロジーではなく、必ずLocalAuthenticationを使用する必要があります。また、13歳未満のユーザーに対しては、必ず代替の認証方法を用意する必要があります。

考えた点・補足

  • 端末のKeychainに直接メールアドレスとパスワードを保管するのは避けた
  • シグネチャは、改ざん防止ではなく、認証のために利用している
  • 顔・指紋認証に3回失敗すると、通常ログインに切り替える〜みたいな処理は、API(ライブラリ)側で勝手にやってくれる

結局違う設計にしました。この記事参照

IonicでiOSの生体認証(Face ID, Touch ID)を扱う

モチベーション

業務でFace ID, Touch ID対応のiOSアプリを作成することになったので、触っておきたい。

バージョン情報

Angular(JavaScriptのWebフレームワーク)ベースの、ハイブリットモバイルアプリ用フレームワークである「Ionic」を使っています

  • ionic-angular@3.9.2
  • Angular@5.2.10

cordova-plugin-touch-idをインストール

Ionicアプリ上で、Face ID / Touch IDの機能を使うには、cordova-plugin-touch-idのインストールが必要になる。

$ ionic cordova plugin add cordova-plugin-touch-id
$ npm install --save @ionic-native/touch-id

cordova-plugin-touch-idをインストールすることで、Face ID / Touch IDの両機能を利用することが可能になる。

AppModuleにプラグインを追加

  • TouchIDモジュールをインポート
  • providers: []配列にTouchIDモジュールを追加

src/app/app.module.ts:

・・・

import { TouchID } from '@ionic-native/touch-id';

@NgModule({
  ・・・
  providers: [
    ・・・
    TouchID,
    ・・・
  ]
})
export class AppModule {}

これで、環境構築は完了🍺🍺🍺

実装

import { Component } from "@angular/core";
import { TouchID } from "@ionic-native/touch-id";

@Component({
  selector: "page-home",
  template: `<ion-content padding>
              <button (click)="login()" ion-button>Login</button>
            </ion-content>`
})
export class HomePage {
  constructor(private touchId: TouchID) {}

  login() {
    // Touch ID, Face ID 対応端末かどうか確認する
    this.touchId
      .isAvailable()
      .then(
        res => console.log("TouchID is available!"),
        err => console.error("TouchID is not available", err)
      );

    // 確認ダイアログを出したあと、Touch ID, Face ID 認証を実行する
    this.touchId
      .verifyFingerprint("Scan your fingerprint please")
      .then(res => console.log("Ok", res), err => console.error("Error", err));
  }
}

動作確認

シミュレーター上での動作確認について。シミュレーターを起動して、Hardware -> Face ID -> Enrolled を選択することで、シミュレーター上でもFace ID, Touch IDの動作確認を実施することができる。

f:id:l08084:20180602190520p:plain

参考サイト

【メモ】Xcodeでエラー「Showing All Messages clang: error: linker command failed with exit code 1 (use -v to see invocation)」

Xcodeで実機ビルドをしたタイミングで、下記のエラーが発生した。

Showing All Messages
clang: error: linker command failed with exit code 1 (use -v to see invocation)

バージョン情報

  • Xcode: 9.3
  • cordova-plugin-touch-id: 3.3.1

原因

cordova-plugin-keychain-touch-idcordova-plugin-touch-idの両方を入れたことがおそらく原因だと思う。
cordova-plugin-keychain-touch-idのインストールをやめて、cordova-plugin-ios-keychainに変更したところ、解決した。

機能が一部、重複しているライブラリを入れたことが原因なのか....?

  • cordova-plugin-touch-id

    • Ionicアプリ上で、Face IDとTouch IDによる認証機能を呼び出すライブラリ
  • cordova-plugin-keychain-touch-id

    • Ionicアプリ上で、Face IDとTouch IDによる認証機能と、Keychainへのパスワード登録機能を可能にするライブラリ