【連載】 ログイン機能を作ってみよう
💡 この記事は GMOペパボ インターンシップ2020 の連載記事です。
こんにちは!引き続き dojineko がお送りします。予定しているカリキュラムの4番目の内容となります。前回までは基礎的な内容でしたが少し踏み込んだ内容になります。
■ カリキュラム
・マネクラの構成紹介
・初めてのWebAPIを作ってみよう
・データベースを使ってみよう
・ログイン機能を作ってみよう ← イマココ
・ミニブログを作ってみよう
・ミニブログに機能を追加してみよう
🐶 今回作るアプリケーション
ここまで作ってきたアプリケーションにログイン機能を実装してみます。
ログイン機構には Auth0 や Firebase Authentication などの便利な iDaaS が存在しますが、今回はあえてそれらは使わず自前で実装してみることにしましょう。
🍿 今回のログイン機構に必要な知識
■ 暗号化とハッシュ化
ログインに必要な情報として今回はメールアドレスとパスワードの2つをアプリケーションは受け取ることとします。この2つはどちらも扱いに注意しなければいけない情報となり、通常暗号化したりハッシュ化したりして情報を守る必要があります。暗号化もハッシュ化も、特定の入力を元に別の情報に変換することが共通していますが、通常、暗号化は可逆でありハッシュ化は不可逆な点に違いがあります。また、ハッシュ化は入力した値に対して常に同一の結果を得られます。
■ ソルトの付加
ハッシュ化した情報は基本的に不可逆ですが、入力に対して常に同一の結果を得られる特性のため、元データとハッシュの組み合わせを膨大に記録したデータが有れば、内容を推測することができてしまいます。そこで、入力値にランダムな情報を付加した上でハッシュ化することで、ハッシュから元データを推測しにくくする手法があります。このとき付加する情報を「ソルト」と呼びます。
■ ハッシュのストレッチング
ソルトと同じく生成したハッシュの強度を強化する手法の一つがストレッチングです。こちらは、入力値から生成したハッシュを数千回以上再度ハッシュ化することで元データの推測しにくく、総当たり攻撃をしにくくする手法です。
■ JSON Web Token (JWT)
JSONデータに署名や暗号化の仕組みを標準化したものです。トークンには任意の情報を格納しながら、鍵による暗号化が行われているので安全に情報を取り扱うことができます。また利用可能な有効期限を設定できるためトークンを生成してから指定時間以上は利用できないようになっています。
■ JWTを使ったときのログアウト処理
JWT自体はステートレスな仕組みであり、JWTを利用したログイン処理においてJWT単体をログイントークンとして使用すると、アプリケーションから明示的にログイントークンを無効化できないため、そのままではログアウト処理を実装することができません。そこで今回は、ステートフルJWTと呼ばれる方法を利用し、JWT自体にはログイン時にアプリから発行する共通鍵のみを暗号化した上で格納し、それをもとにログイン状態とログアウト状態を決定するようにします。
📟 データベースを用意する
まずサインアップに必要なテーブルを準備します。以下の内容を「entities/user.ts」として保存します。
import { Entity, PrimaryColumn, Column, CreateDateColumn } from 'typeorm'
@Entity({ name: 'users' })
export class UserEntity {
@PrimaryColumn({ type: 'uuid' })
public id!: string
@Column({ type: 'varchar' })
public encryptedEmail!: string
@Column({ type: 'varchar' })
public salt!: string
@Column({ type: 'varchar' })
public passwordHash!: string
@CreateDateColumn({ name:'created_at', type: 'timestamp' })
public createdAt!: Date
}
また以下の内容を「entities/session.ts」として保存します。
import { Entity, PrimaryColumn, Column, CreateDateColumn, OneToOne } from 'typeorm'
import { UserEntity } from './user'
@Entity({ name: 'sessions' })
export class SessionEntity {
@PrimaryColumn({ type: 'uuid' })
public id!: string
@Column({ type: 'uuid' })
@OneToOne(() => UserEntity)
public userId!: string
@Column({ type: 'varchar' })
public token!: string
@CreateDateColumn({ name:'created_at', type: 'timestamp' })
public createdAt!: Date
}
最後に UserEntity と SessionEntity を importして、TypeORM の設定の entities に user と session をそれぞれ追加します。
import { UserEntity } from './entities/user'
import { SessionEntity } from './entities/session'
await createConnection({
synchronize: true,
type: 'mysql' as const,
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT ? parseInt(process.env.DB_PORT, 10) : 3306,
username: process.env.DB_USER || 'myapp',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_NAME || 'myapp',
entities: [
NoteEntity,
UserEntity, // ←追加
SessionEntity, // ←追加
],
})
以上でデータベースの準備はできました。この後TypeORMの機能を追加で利用するので TypeORM の import 文を下記のように変えておきましょう。
import { createConnection, getManager, LessThan } from 'typeorm'
📦 パッケージの追加
今回使用するパッケージを追加します。
npm install jwt-simple @types/jwt-simple
index.ts に import 文を追加しておきましょう。
import crypto from 'crypto'
import jwtSimple from 'jwt-simple'
🏜 サインアップ処理を作ろう
まず、ログイン時にJWTを作成する処理を追加します。
const jwtKey = process.env.JWT_KEY || 'dummy'
const jwtAlgo = 'HS256'
const revokeOldSession = async (userId: string, exp: number) => {
const threshold = new Date()
threshold.setHours(threshold.getHours() - exp)
const mgr = getManager()
await mgr.delete(SessionEntity, { userId, createdAt: LessThan(threshold) })
}
const makeSession = async (userId: string): Promise<string> => {
const exp = 12
const mgr = getManager()
const result = await mgr.save(SessionEntity, {
id: uuid(),
userId,
token: uuid(),
})
await revokeOldSession(userId, exp)
const unixNow = new Date().getTime() / 1000
return jwtSimple.encode({
sub: uuid(),
iat: Math.floor(unixNow),
exp: Math.floor(unixNow + (exp * 60 * 60)),
userId,
token: result.token
}, jwtKey, jwtAlgo)
}
今回はトークンは12時間有効とします。またトークンを生成すると同時に12時間以上経過しているSessionは削除して無効化するようにしています。Sessionが作成できたらJWTとして結果を返却しています。
続いて簡易的な暗号処理とハッシュ化の処理を追加します。今回は進行のためかなり簡易的な実装になっているので本番のアプリケーションで利用する場合は注意しましょう。
const cryptAlgo = 'aes-256-cbc'
const cryptoPassword = process.env.CRYPTO_PASSWORD || 'cryptoPassword'
const cryptoSalt = process.env.CRYPTO_SALT || 'cryptoSalt'
const cryptoKey = crypto.scryptSync(cryptoPassword, cryptoSalt, 32)
const cryptoIv = process.env.CRYPTO_IV || '0123456789abcedf'
const encrypt = (plaintext: string) => {
if (plaintext === '') {
return ''
}
const cipher = crypto.createCipheriv(cryptAlgo, cryptoKey, cryptoIv)
let ciphertext = cipher.update(plaintext, 'utf8', 'base64')
ciphertext += cipher.final('base64')
return ciphertext
}
const decrypt = (ciphertext: string) => {
if (ciphertext === '') {
return ''
}
const decipher = crypto.createDecipheriv(cryptAlgo, cryptoKey, cryptoIv)
let plaintext = decipher.update(ciphertext, 'base64', 'utf8')
plaintext += decipher.final('utf8')
return plaintext
}
const hashStretch = process.env.HASH_STRETCH ? parseInt(process.env.HASH_STRETCH, 10) : 5000
const makeHash = (data: string, salt: string) => {
let result = crypto.createHash('sha512').update(data + salt).digest('hex')
for (let i = 0; i < hashStretch; i++) {
result = crypto.createHash('sha512').update(result).digest('hex')
}
return result
}
続いて用意したデータベース、暗号化機構、ハッシュ機構を利用してサインアップ処理を追加します。index.ts に下記のように実際のサインアップ処理を追加します。リクエストからメールアドレスとパスワードを受け取り、新たにユーザーを追加できた場合はログイントークンを返却します。既にメールアドレスの登録がある場合は 409 をレスポンスします。
app.post('/api/signup', wrap(async (req, res) => {
const email: string = req.body.email || ''
const password: string = req.body.password || ''
if (!email || !password) {
res.sendStatus(400)
return
}
const encryptedEmail = encrypt(email)
const mgr = getManager()
const user = await mgr.findOne(UserEntity, { encryptedEmail })
if (user) {
res.sendStatus(409)
return
}
const salt = uuid()
const result = await mgr.save(UserEntity, {
id: uuid(),
encryptedEmail,
salt,
passwordHash: makeHash(password, salt),
})
const token = await makeSession(result.id)
res.status(201).json({ token })
}))
🎡 ログイン処理を作ろう
ここではサインアップ処理と同じくJWTの生成を行ってレスポンスするようにしています。もしユーザーがいない場合は 404 をレスポンスし、パスワードが間違っている場合は 403 をレスポンスするようにしています。
app.post('/api/login', wrap(async (req, res) => {
const email: string = req.body.email || ''
const password: string = req.body.password || ''
if (!email || !password) {
res.sendStatus(400)
return
}
const encryptedEmail = encrypt(email)
const mgr = getManager()
const user = await mgr.findOne(UserEntity, { encryptedEmail })
if (!user) {
res.sendStatus(404)
return
}
if (user.passwordHash !== makeHash(password, user.salt)) {
res.sendStatus(403)
return
}
const token = await makeSession(user.id)
res.status(200).json({ token })
}))
🎢 APIの操作に認証をつけよう
前回まで作成してきた、メモを記録するAPIに対して今回作ったログイン機構を連携してみましょう。まず以下の内容を types/express/index.d.ts として保存します。これは TypeScript で Express の Request 型を拡張するためのものです。
import 'express'
declare global {
namespace Express {
interface Request {
userId: string
token: string
}
}
}
TypeScript の型に関する情報は下記を参照すると良いでしょう。
続いて、以下のコードをindex.tsの「app.use(bodyParser.json());」の次の行以降に追加します。この後に出てくる 「/api/hello」のハンドラよりも前にこの定義がされている必要があります。
app.use(wrap(async(req, res, next) => {
if (/^\/api\/(signup|login)$/.test(req.path)) {
next()
return
}
const parts = req.headers.authorization ? req.headers.authorization.split(' ') : ''
const token = parts.length === 2 && parts[0] === 'Bearer' ? parts[1] : null
if (!token) {
res.sendStatus(403)
return
}
const payload = jwtSimple.decode(token, jwtKey, false, jwtAlgo)
const mgr = getManager()
const session = await mgr.findOne(SessionEntity, { token: payload.token, userId: payload.userId })
if (!session) {
res.sendStatus(403)
return
}
req.userId = payload.userId
req.token = payload.token
next()
}))
signup、login のエンドポイントでは認証要求を行わず、それ以外のエンドポイントでは Authorization ヘッダーから JWT を取り出してデコードし、セッションが有効かどうかをチェックするようになっています。正常に通過した場合は、リクエストオブジェクトに対して userId と token を組み込んでいます。
🗿 ログアウト処理を作ろう
最後にログアウト処理を追加します。ここではシンプルにログイン済みのJWTに対応する Session を削除することでログアウト状態を実現しています。次回以降は同じJWTを使用しても対応する Session が無いためログイン状態とならないという仕組みです。
app.post('/api/logout', wrap(async (req, res) => {
if (!req.userId || !req.token) {
res.status(403)
return
}
const mgr = getManager()
await mgr.delete(SessionEntity, { userId: req.userId, token: req.token })
res.sendStatus(204)
}))
👑 ログイン状態を確認できるエンドポイントにしよう
一番最初に作った /api/hello を以下のように書き換えます。ログイン状態の場合にはメールアドレスをレスポンスしてくれるようになります。
app.get('/api/hello', wrap(async (req, res) => {
const mgr = getManager()
const user = await mgr.findOne(UserEntity, { id: req.userId })
if (!user) {
res.sendStatus(404)
return
}
res.json({ hello: decrypt(user.encryptedEmail) })
}))
🚙 試してみよう
ここまでできたらそれぞれ動作確認してみましょう。まずは認証無しでAPIを実行してみます。403エラーとなれば成功です。
# 認証無しでAPIを実行する
curl -v -X GET -H 'content-type: application/json' http://localhost:3000/api/hello
続いてサインアップを試してみます。サインアップ後にログイントークンをがレスポンスされますが、ここではまだ使いません。
# サインアップを実行する
curl -v -X POST -H 'content-type: application/json' -d '{ "email": "test@example.test", "password": "dummy" }' http://localhost:3000/api/signup
# レスポンス例: {"token": "<ここがトークンです>"}
続いてログインも試してみましょう。正常に動作すればレスポンスとしてログイントークンを得られます。
# ログインを実行する
curl -v -X POST -H 'content-type: application/json' -d '{ "email": "test@example.test", "password": "dummy" }' http://localhost:3000/api/login
# レスポンス例: {"token": "<ここがトークンです>"}
今度は取得したログイントークンを利用して先程のAPIを実行してみましょう。
# 認証付きでAPIを実行する
curl -v -X GET -H 'content-type: application/json' -H 'authorization: Bearer <トークン>' http://localhost:3000/api/hello
正常に結果を得られればOKです。最後はログアウト処理を実行してみます。直後に同じJWTを利用してAPIを実行できなくなっているはずです。
# ログアウトAPIを実行する
curl -v -X POST -H 'content-type: application/json' -H 'authorization: Bearer <トークン>' http://localhost:3000/api/logout
# ログアウト済みのJWTでAPIを実行する
curl -v -X GET -H 'content-type: application/json' -H 'authorization: Bearer <トークン>' http://localhost:3000/api/hello
すべて動作を確認できたら、これまでと同じく git コマンドを利用してマネクラにデプロイしてみましょう。
# 変更を記録対象にする
git add .
# コミットを作成する
git commit -m "use authentication"
# リモートに転送する
git push lolipop master
📚 まとめ
今回は以上となります。アプリケーションにログイン機構を実装するに当たり最低限必要な知識を学び、実際に機能を追加することを行いました。独自でログイン機構を作成するためのイメージができたのではないかと思います。
なお、ここまでのコードは、進行を優先したためコード分割などは特に行わない状態で進めてきたため見通しがあまり良くありません。そこで、ある程度配置し直したものを以下のリポジトリで公開しています。お時間がありましたら、見比べてみてどのようにすればまとまりを保ちつつ分割できるか考えてみるのも良いかもしれません。
さて、次回はいよいよフロントエンドの機構を利用してブラウザからAPIを操作できるようにした上で、ミニブログを作成してみます。お楽しみに!