はじめに
初めまして。株式会社ルーデルに25卒のエンジニアとして入りました、松永と申します。
大学時代にちまちまと自作のWebアプリを制作していて、「仕事でもものづくりしたいな」と思いこの会社に入社しました。
この記事では、今年の研修にて行われたバックエンドエンジニアリング研修の内容をお伝えしていきます。
この研修を通して、ウェブサイト開発における裏方であるバックエンドの概要と構築を学び、後半には自ら機能を考えて実装するハンズオンを行いました。
研修の流れ
この研修記事は以下の様な流れで説明します。
- バックエンドの概要と技術説明
- そもそもバックエンドとは何か
- 認証認可について
- フロントエンド開発研修に付随したAPI設計図からAPIの実装
- フロントエンドとバックエンドの繋ぎ込み
バックエンドの概要と技術説明
そもそもバックエンドとは
そもそもバックエンドと言われても何よ、という方もいらっしゃると思います。
簡単にいうと、ブラウザからは見えない処理をしてくれる縁の下の力持ちです。
まず前提となる基本的なブラウザの動作として、Webページの情報が置いてあるサーバーに対して、「この情報(URLとか巷で噂のクッキーとか)を元にページの情報を送ってくれ」とお願いをします。
そのお願いを聞いたサーバーが情報に合わせたWebページを送ってくれるので、それをブラウザは表示しているわけです。
ここでは一般的なショッピングサイトを例にしたいと思います。
例えば商品を買おうとした時、ブラウザの画面はこのように進むと思います。
この流れの中で、一例としてバックエンドはこんな処理をしています。
- 検索文字を処理して検索をする処理
- データ置き場から商品の詳細をとってくる処理
- カートを表示しようとしている人は本当に本人かの確認
- カートに商品を追加する処理
- 注文決済処理
他にも色々と処理はあると思いますが、ざっとこんな感じです。
このように、表側からは見えないが、アプリを動かす上で必要なデータの処理をバックエンドは担当しています。
ちなみに、対となる言葉でフロントエンドって概念がありますが、こちらは表示とUI全般を担ってくれています。
ユーザーさんから一番見える部分ですね。
ちなみに弊社では、この研修の前にフロントエンドに関する研修も行なっています。
フロントエンドの研修記事はこちら↓
また、このバックエンドとフロントエンドはサーバーが基本的に分かれています。
理由として、維持・管理が楽になるからです。プログラムの量が両方とも膨大になりがちなので、分けた方が見やすいんですね。
で、この分かれた二つのサーバーを繋ぎ合わせるためにAPIというものが出てきます。
APIとは
API、よく耳にしますね。よく叩くなんて言われていますが、物理的にサーバー引っ叩いてるわけではないです。
人間が金属に勝てるわけないよ...
これは何かというと、プログラム同士がやりとりする際の取り決めです。
よく映画なんかで、合言葉を言うと開く扉があると思うんですが、ああいった「合言葉を言う」→「扉が開く」みたいなのと一緒ですね。
APIには以下の取り決めがあります。
- 呼び出す時に必要な 名前
- 必要な値である パラメータ
- 得られる値 返り値
で、今回使うのはWebで使われるAPI、Web APIというものです。まんまですね。
APIには他にも「ランタイムAPI」や「ネイティブAPI」など、様々あるのですが、今回はWebAPI一本で解説していきます。
例えばショッピングカートに追加する処理を例としてあげると、APIは
- 名前 : [PUT] /api/cart/
- パラーメータ : 追加した人の情報、追加した商品の情報
- 返り値 : 成功したかどうか
こんな形になります。
フロントエンドが名前とパラメータを送り、バックエンドがそれを元に返り値を返す。この様な流れになります。
これを用いて、サーバーとインターネット経由でやり取りすることで、サーバー同士が意思疎通をとれるわけです。
認証認可について
さて、ショッピングサイトでカートの追加や決済、履歴の確認などはプライバシー情報になります。
なので、当然他人から隠さなきゃいけないわけで、見るために「本当にその人なのか?」を確認する必要があります。
対面で会うならば、顔やら声やらで確認できますが、コンピューターを通じて行う場合はどうすればいいのでしょう?
また、例えば一般の人がお店の管理画面をいじって商品を消してしまったり、他の人のカートまで見えてしまったら非常に困りますよね?
そこで登場するのが認証と認可という概念です。
どちらもそのままの意味で、認証はログインしているユーザーが本人であるか確認して、合っていれば認証するという概念で、
以下の3要素を使って認証を行っています。
-
知識情報
- その人しか知らない情報を元に認証(パスワードとか、秘密の質問とか)
-
所持情報
- ユーザーが物理的に所持しているものに含まれる情報(スマホでOKボタンを押すとか、最近の二要素認証アプリとか)
-
生体情報
- ユーザーの生物学的な情報(指紋とか、目とか、顔とか)
認可もそのままの意味で、見せて大丈夫な情報だけ認可するという概念です。
一般のユーザーにはお店の管理画面を見せないようにしたり、その人のカートの中身しか見えないようにしたりしています。
ハンズオン ~実際にバックエンドの仕組みを作ってみよう~
この研修のハンズオンでは、ExpressというフレームワークとTypeScript言語、インフラを管理するDockerを用いて実際にバックエンドプログラムを書くハンズオンを行いました。
基本的な機能の確認や実装の後、フロントエンド研修で作成したデリバリーアプリとの繋ぎこみを行いました。
はじめに、ルーティングとコントローラーのコーディングを行って、簡単にレスポンスを返すところから始めました。
//~/app.ts
app.use("/api/users",userRouter);
//~/routes/userRouter.ts
userRouter.post("register", register);
//~/Controllers/userController.ts
export const register = async(req: Request, res:Response) => {
const name: string = req.body.name;
res.status(201).json({message: "User registerd!!" });
};
このコードによって、(url)/api/user/register
にアクセスした際、 201 User registerd!!
という値が返ってきます。
また、name
という定数にリクエストで受け取った名前が入る様になります。
このように、アクセスがあった際にどこで処理するかを指定し、コントローラーというデータ処理を担当する部品で処理をすることで、レスポンスを返すことが出来る訳です。
また、もしデータベースにアクセスをしたい場合は、コントローラーからモデルというデータを取ってきてくれるプログラムを介してデータベースにアクセスをします。
一例として、カートの中身をアップデートしてくれるコードが下記になります。
import{ updateCartItem } from "../models/cartModels";
export const updateCartItems = async (
connection: PoolConnection,
cartId: number,
items: { itemId: number; quantity: number }[]
) => {
try {
for (const item of items) {
await updateCartItem(connection, cartId, item.itemId, item.quantity);
}
} catch (error) {
console.error("Error updating cart items:", error);
throw new Error("Failed to update cart items");
}
};
export const updateCartItem = async (
connection: PoolConnection,
cartId: number,
itemId: number,
quantity: number
) => {
await connection.execute<ResultSetHeader>(
"UPDATE cart_items SET quantity = ? WHERE cart_id = ? AND item_id = ?",
[quantity, cartId, itemId]
);
};
これでコントローラーからモデルに値を渡し、モデルが渡された値を元にデータベースに手を加えてくれます。
この様に、フロントのUI(ビュー)と、データ処理をするコントローラーと、データベースにアクセスするモデルを合わせた構造をMVCモデルといいます。
担当分野を分けてコードを分割することで、機能ごとのコード量を減らしたり、メンテナンス性をあげたり出来るメリットがあります。
ちなみに、私は最初コントローラーから直接データベースにアクセスしてこの構造を破壊していました。
export const updateCartItems = async (
connection: PoolConnection,
cartId: number,
items: { itemId: number; quantity: number }[]
) => {
try {
for (const item of items) {
await connection.execute(
"UPDATE cart_items SET quantity = ? WHERE cart_id = ? AND item_id = ?",
[item.quantity, cartId, item.itemId]
);
}
} catch (error) {
console.error("Error updating cart items:", error);
throw new Error("Failed to update cart items");
}
};
これを行ってしまうと、機能を分離した際のメリットを手放すことになるので厳禁です。
後々コードを見返した際に、過去の自分にブチギレることになります。
この様にしてチュートリアルを終え、次のステップとして「バックエンド研修のプロジェクト内で実装されきっていない機能を実装し、フロントエンド研修で作成したアプリと繋ぎ合わせる」というハンズオンを行いました。
事前のフロントエンド研修にてOpenAPI Specificationというフォーマットで作られたWebAPIの仕様書が配られており、まずはそれらの実装を行いました。
この実装によって、バックエンドのコントローラーロジックだけでなく、モデルのロジックの中でどう言ったSQLクエリを与えるかなど、コードだけでなくデータベースのことも考えさせられ、どう言った形でWebとデータベースが連携されているかを実感できました。
魔改造タイム
さて、これで我々研修生は晴れて一つのアプリケーションを完成させることができました。
しかし、研修生各々が(ここを手直ししたいし、こういう機能つけたいな)とも感じていました。
そんな時に始まったのです。魔改造タイムが。
ここから先は、フロントエンドもバックエンドも区別なしで、自分の作ったデリバリーアプリを好きなように改造しよう、というハンズオンが始まりました。
- 公序良俗と技術での実装に従うならネタに走るもよし!
- 細かい改変でも大きい改変でもOK!とにかく思いつくことをやってみよう!
というルール(?)で行われました。
そんなハンズオンの中で、私は2FA(2要素認証 スマホに来た6桁のパスワードを求められるやつ)の実装をしようと考えました。
というのも、ちょうどこの研修中に友人がアカウント乗っ取り被害に遭っており、2要素認証を含めてセキュリティ固めなきゃいけないねと話していた経緯があります。
そこで、このサイトにも二要素認証を追加する術はあるだろうと思い、実装してみたいと考えた訳です。
実装する上でパッケージの剪定を行うのですが、今回の二要素認証実装で候補に上がったのは以下の二つでした。
- speakeasy
- otplib
私はこの二つから、機能が少なくシンプルなspeakeasyをチョイスしました。
あと、2FAの暗号化周りで標準パッケージのCryptoを利用しました。
これらのパッケージを組み合わせて実装を進めていくのですが、そもそも現在のデータベース上にあるユーザーデータの項目では対応できませんでした。
なのでまずはデータベースの構造を改変することにしました。
SQL追記分
--2FAの鍵情報
CREATE TABLE IF NOT EXISTS user_2fa (
id INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT,
user_id VARCHAR(64) NOT NULL,
secret VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME DEFAULT NULL
);
--2FAのリカバリーコード
CREATE TABLE IF NOT EXISTS recovery_codes (
id INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT,
user_id VARCHAR(64) NOT NULL,
code VARCHAR(96) NOT NULL,
iv VARCHAR(16) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
まずはモデルとその周りから作っていきます。
userModel.ts
//2FAを登録する
export const create2fa = async (userId: string, secret: string): Promise<void> => {
await db.execute<ResultSetHeader>(
"INSERT INTO user_2fa (user_id, secret) VALUES (?, ?)",
[userId, secret]
);
}
//ユーザーが2FAを登録しているか検出する
export const find2faByUserId = async (userId: string): Promise<RowDataPacket | null> => {
const [rows]: [DBUser[], FieldPacket[]] = await db.execute(
"SELECT id, secret FROM user_2fa WHERE user_id = ? AND deleted_at IS NULL",
[userId]
);
if (rows.length === 0){
return null;
}
return rows[0];
}
//2FAのリカバリーコードを生成する
export const generate2FARecoveryCodes = async (userId: string) => {
let recoveryCodes: string[] = [];
let encryptedRecoveryCodes: string[] = [];
let ivs: string[] = [];
for (let i = 0; i < 10; i++) {
const code = crypto.randomBytes(16).toString('hex');
recoveryCodes.push(code);
}
for (const code of recoveryCodes) {
const iv = crypto.randomBytes(16).toString('hex');
ivs.push(iv);
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(secret, 'hex'), Buffer.from(iv, 'hex'));
const encrypted = cipher.update(code, 'utf8', 'hex');
const final = cipher.final('hex');
encryptedRecoveryCodes.push(encrypted + final);
}
for (let i = 0; i < encryptedRecoveryCodes.length; i++) {
await db.execute<ResultSetHeader>(
"INSERT INTO recovery_codes (user_id, code, iv) VALUES (?, ?, ?)",
[userId, encryptedRecoveryCodes[i], ivs[i]]
);
}
return recoveryCodes;
}
//入力されたリカバリーコードを検証する
export const existsByRecoveryCode = async (userId: string, recoveryCode: string): Promise<boolean> => {
const [rows]: [DBUser[], FieldPacket[]] = await db.execute(
"SELECT code, iv FROM recovery_codes WHERE user_id = ? AND deleted_at IS NULL",
[userId]
);
for (const row of rows) {
const deciper = crypto.createDecipheriv('aes-256-cbc', Buffer.from(secret, 'hex'), Buffer.from(row.iv, 'hex'));
let decrypted = deciper.update(row.code, 'hex', 'utf8');
decrypted += deciper.final('utf8');
if (decrypted === recoveryCode) {
return true;
}
}
return false;
}
メソッドの概要はコメントアウトで書いた通りなのですが、暗号化してデータベース保管することをモデルでやっています。
次にコントローラーです。
userController
//export const login 一部抜粋
...
try {
const user = await findByEmail(email);
if (!user) {
res.status(400).json({ message: "Invalid email or password" });
return;
}
const user2fa = await find2faByUserId(user.uuid);
const isPasswordValid = await bcrypt.compare(password, user.password);
if (isPasswordValid) {
//もし2FAが設定されていればJWTで認証の時間制限をかけて2FAに誘導(実際の移動はフロントエンドにて行う)
if (user2fa){
const token = jwt.sign({ userId: user.uuid }, config.secret, { expiresIn: "10m" });
res.status(202).json({message: "2FA required", token: token});
return;
}
//2FAを利用していない場合はそのまま通常ログイン動作
req.session.uuid = user.uuid;
req.session.expirationTime = Date.now() + sessionExpirationTime;
req.session.role = "user";
req.session.save();
res.status(200).json({ message: "Login successful" });
}
}
...
//2FAの登録
export const register2fa = async (req: Request, res: Response) => {
const userId: string | undefined = req.session.uuid;
const secret = Speakeasy.generateSecret({ length: 20 }); //speakeasyを用いてシークレットを作成
if (userId === undefined) {
res.status(400).json({ message: "User is not logged in" });
return;
}
try{
const recoveryCodes = await generate2FARecoveryCodes(userId); //リカバリーコード生成
await create2fa(userId, secret.base32); //上で作ったシークレットをもとに2FAに必要な情報を作成・保存
const otpauthUrl = secret.otpauth_url; //QRコード用のURL作成
res.status(200).json({ message: "2FA registered", otpauthUrl: otpauthUrl, recoveryCodes: recoveryCodes });
}catch(e: unknown){
console.error(e);
res.status(500).json({ message: "Internal server error" });
}
}
// 2FAの認証
export const verify2fa = async (req: Request, res: Response) => {
const token = jwt.verify(req.body.token, config.secret);
const userId = (token as { userId: string }).userId;
const oneTimePassword = (req.body.oneTimePassword).trim();
if (!oneTimePassword){
res.status(400).json({message: "Invalid email or password"});
return;
}
const user2fa = await find2faByUserId(userId);
if (!user2fa){
res.status(400).json({message: "2FA is not registered"});
return;
}
if (token === undefined || null){
res.status(400).json({message: "User is not logged in"});
return;
}
const verified = Speakeasy.totp.verify({ //2FAの認証処理
secret: user2fa.secret, //シークレットキー
token: oneTimePassword, //入力されたワンタイムキー
encoding: "base32",
window: 1, //一個前のパスワードも承認する(時間同期ずれ対策)
});
if (!verified){
res.status(400).json({message: "Invalid token"});
return;
}
req.session.uuid = userId;
req.session.expirationTime = Date.now() + sessionExpirationTime;
req.session.role = "user";
req.session.save();
res.status(200).json({message: "2FA verified"});
}
コントローラーのメソッドも各種コメントを見ていただければと思うのですが、主にspeakeasyを用いて秘密鍵を作ったり、ワンタイムパスワードの認証をしています。
speakeasyのverifyメソッドに時間ずれ対策のオプションが用意されているのを初めて知り、若干遅れても入力できたのはこのオプションのお蔭だったのかと気づきました。
発表会
さて、私はこのようにバックエンド側の改造をしまして、最後にメンバーたちの改造を見て回れる発表会が行われました。
気になる他メンバーの改造ですが、メンバーによってかなり改造内容が違っており、ほぼ全員が全く違う改造を行なっていたのが非常に印象的でした。全員個性派すぎる・・・
例えば...
- バックエンドを丸ごとhonoで再構築する人
- フロントエンドにReact Three Fiberを導入して3Dのメニューを描画する人
- メニュー内で1000円以内の組み合わせを作るガチャを実装した人
- MCPを実装して、LLMから自然言語での注文や、注文履歴を追える様にした人
- スマートリングと連携させて健康の管理機能を実装した人
- etcetc...
特にフロントエンドに3Dを持ち込むアイデアはすごく斬新でしたし、技術的に可能なのかと驚きました。
各々のメンバーが、使用したパッケージの情報や参考にした情報の共有などを行い、非常に学びのある発表会となりました。
まとめ
フロントエンド研修とバックエンド研修を通して、webアプリケーションの仕組みを体系的に学ぶことができたと思います。また、自ら改善点を考えてそれを実装することで、自分自身の独創性と主体性を伸ばせましたし、他のメンバーのアイデアを見ることによって、自分自身になかったアプローチで物を見てみることができたと思います。
技術面では、Dockerの基礎知識やMVCモデルの考え方を学ぶことができ、モダンなweb開発にも慣れることができたと思いますし、自らセキュリティ関連の実装をすることで、いつも使われている認証がなぜ堅牢なセキュリティなのか、そのセキュリティ堅さはどのように生み出されているのかも調べることができて、非常に為になる時間になりました。
▼採用情報
レアゾン・ホールディングスは、「世界一の企業へ」というビジョンを掲げ、「新しい"当たり前"を作り続ける」というミッションを推進しています。
現在、エンジニア採用を積極的に行っておりますので、ご興味をお持ちいただけましたら、ぜひ下記リンクからご応募ください。