バックエンド側 node.js 外部API呼び出し/認証

2周ぐらい周回遅れなのか探す情報が古いのが出てくる時がある~パクリ19発目!
パクリ元
内容
以下一覧を完了させて ECS リリースさせることでバックエンド側を終わりとする。順序は適宜入れ替える。
- 開発環境構築
- プロジェクト作成・最適なフォルダ構成
- 認証チェック(含めた前処理)
- ログ処理
- APIによってはIPチェックやHeaderチェック
- バリデーション
- 環境ファイルによる振り分け
- DB参照・更新
- 外部API呼び出し ←←← 今回ココから。
- 外部API呼び出し待ち合わせ
- (認証・認可) フロントエンドとの連携が必要なので今回省略。としておいたがやはりやる。
- cors csrf spa
- 長時間処理を非同期処理で
- メール送信
- CSVアップロード
- ファイルダウンロード
- EXCEL/PDF作成
- テスト
- AWS環境へデプロイ
外部API呼び出し
バックエンド側から外部APIを呼ぶ。
フロントエンド側から呼ぶ場合(取得した情報を表示する)とは目的が異なる。
・業務ロジック中に付加データを取得して一緒に保存したい。
・呼び出し状況を把握したい(ログで状況を知りたい)。
アプリが動かないと苦情が来たが、実は外部API側でエラーになっていて全く気付かない場合があるため。
・呼び出し回数を把握したい(ログで状況を知りたい)。
従量制料金の場合、どのユーザーが悪魔的なアクセスをしているかを把握するため。
GoogleAnalytics だけに頼ることはできないし。
・フロントエンド側からリソースを取得するための署名/トークンを取得したい。
とりあえず投入したデータを全削除し、テーブル定義を変更、それに合わせデータ投入もやめることとする。
・・・
model User {
id Int @id @default(autoincrement())
email String @unique
name String
zipcode String?
address String?
latitude Float?
longitude Float?
}
・・・
/* コメントアウトする
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
*/
~/nodetest$ npx prisma migrate reset
・・・
✔ Are you sure you want to reset your database? All data will be lost. … yes
・・・
~/nodetest$ npx prisma db push
~/nodetest$ npx prisma migrate dev
・・・
✔ Enter a name for the new migration: … modify_user_add_column
Applying migration `20231025114158_modify_user_add_column`
・・・
次にパクリ元1つ目を参考に API 呼び出し部分を作成する。パクリ元2つ目にはタイムアウトの設定がある。
import { getLogger } from "log4js";
export default {
get: async (url: string, ms: number = 2000/*デフォルトタイムアウト2秒*/):Promise<any> => {
getLogger("app").info("fetch: " + url)
let ret: null = null;
return await fetch(url, {
signal: AbortSignal.timeout(ms),
})
.then (async res => {
ret = await res.json();
getLogger("app").info(JSON.stringify(ret));
return ret;
})
.catch (err => {
getLogger("app").error(err);
return ret;
})
},
}
core ていうフォルダに入れちゃったけどこういう場合はどういう名前がいいんだろうね。次にパクリ元3つ目4つ目を参考に「住所から郵便番号取得」「住所から緯度経度取得」関数を作成。
import fetch from '../core/fetch';
import url from '../const/url';
import { getLogger } from 'log4js';
export default {
/**
* 住所から郵便番号を取得する
* @param _address - 住所
*/
getZipcodeFromAddress: async (address: string): Promise<string> => {
return await fetch
.get(url.addressFromZipcodeUrl + encodeURI(address))
.then(data => {
return data? data.toString() : "";
})
},
/**
* 住所から緯度経度を取得する
* @param _address - 住所
*/
getLatlonFromAddress: async (address: string): Promise<number[]> => {
let _latitude: number = -1;
let _longitude: number = -1;
await fetch
.get(url.latlonFromZipcodeUrl + encodeURI(address))
.then(data => {
const _latlon = data;
if (Array.isArray(_latlon) && _latlon.length > 0) {
try {
const obj: object = _latlon[0];
const geometry = obj["geometry" as keyof typeof obj];
const coordinates = geometry["coordinates" as keyof typeof geometry];
_latitude = parseFloat(coordinates[1]);
_longitude = parseFloat(coordinates[0]);
} catch (e: unknown) {
if (e instanceof Error) {
getLogger("app").error(e.message);
}
}
}
});
return [_latitude, _longitude];
},
}
export default {
addressFromZipcodeUrl: "https://api.excelapi.org/post/zipcode?address=",
latlonFromZipcodeUrl: "https://msearch.gsi.go.jp/address-search/AddressSearch?q=",
}
前回より少し関数をまとめて読みやすくモデルを修正。郵便番号の入力がない場合の郵便番号や緯度経度を入力した住所から取得して一緒に保存する、という流れ。
import type { Express, Request, Response, NextFunction } from 'express';
import userModel from '../models/userModel'
export default {
list: async(req: Request, res: Response) => {
res.json(await userModel.getList());
},
insert: async(req: Request, res: Response) => {
const { name, email, address, zipcode } = req.body;
res.json(await userModel.insert(name, email, address, zipcode));
},
update: async(req: Request, res: Response) => {
const { id, name, email, address, zipcode } = req.body;
res.json(await userModel.update(id, name, email, address, zipcode));
},
}
import { Prisma, PrismaClient } from '@prisma/client';
const prisma = new PrismaClient()
export default {
execute: async (func: Function) => {
return func()
.catch((e: any) => {
throw e
})
.finally(() => {
prisma.$disconnect()
})
},
}
import { Prisma, PrismaClient } from '@prisma/client';
import addressModel from './addressModel';
import { getLogger } from 'log4js';
import dbaccess from '../core/dbaccess';
const prisma = new PrismaClient({
log: ['query', 'info', 'warn', 'error'],
})
/**
* ユーザーモデル
*/
export default {
getList: async () => {
const func = async () => {
return await prisma.user.findMany();
}
return dbaccess.execute(func);
},
insert: async (_name: string, _email: string, _address: string, _zipcode: string) => {
// 郵便番号がない場合、住所から郵便番号取得
if (!_zipcode) {
_zipcode = await addressModel.getZipcodeFromAddress(_address);
}
// 住所から緯度経度取得
const _latlon: number[] = await addressModel.getLatlonFromAddress(_address);
const _latitude: number = _latlon[0];
const _longitude: number = _latlon[1];
const func = async () => {
const user = await prisma.user.create({
data: {
name: _name,
email: _email,
address: _address,
zipcode: _zipcode.toString(),
latitude: _latitude,
longitude: _longitude,
},
});
return user;
}
return dbaccess.execute(func);
},
update: async(_id: number, _name: string, _email: string, _address: string, _zipcode: string) => {
// 郵便番号がない場合、住所から郵便番号取得
if (!_zipcode) {
_zipcode = await addressModel.getZipcodeFromAddress(_address);
}
// 住所から緯度経度取得
const _latlon: number[] = await addressModel.getLatlonFromAddress(_address);
const _latitude: number = _latlon[0];
const _longitude: number = _latlon[1];
const func = async () => {
const user = await prisma.user.update({
where: {
id: _id,
},
data: {
name: _name,
email: _email,
address: _address,
zipcode: _zipcode.toString(),//
latitude: _latitude,
longitude: _longitude,
},
});
return user;
}
return dbaccess.execute(func);
},
}
試してみる。
~/nodetestcurl -X POST -H "Content-Type: application/json" -d '{"name": "test12", "email": "aaa@aaa.aaa", "address": "東京都渋谷区渋谷1-1"}' -b "id=a" localhost:3001/user/insert
{"id":1,"email":"aaa@aaa.aaa","name":"test12","zipcode":"1500002","address":"東京都渋谷区渋谷1-1","latitude":35.661224,"longitude":139.707047}
郵便番号も緯度経度も一緒に保存されているのを確認。
外部API呼び出し待ち合わせ
通常それぞれに影響しあうことのないAPI呼び出し等は非同期にして待ち合わせるものである。パクリ元5つ目を参考に非同期の待ち合わせにする。fetch でタイムアウトになっても正常 null を返すようにしているので then に入ってくることになる。
update: async(_id: number, _name: string, _email: string, _address: string, _zipcode: string) => {
let _latitude: number = -1;
let _longitude: number = -1;
if (!_zipcode) {
await Promise.all([
addressModel.getZipcodeFromAddress(_address),// 郵便番号がない場合、住所から郵便番号取得
addressModel.getLatlonFromAddress(_address),// 住所から緯度経度取得
])
.then(res => {
_zipcode = res[0] || "";
const _latlon: number[] = res[1] || [0,0];
_latitude = _latlon[0];
_longitude = _latlon[1];
})
.catch(err => {
getLogger("app").error(err);
_zipcode = "";
_latitude = 0;
_longitude = 0;
})
} else {
// 住所から緯度経度取得
const _latlon: number[] = await addressModel.getLatlonFromAddress(_address);
_latitude = _latlon[0];
_longitude = _latlon[1];
}
const func = async () => {
const user = await prisma.user.update({
where: {
id: _id,
},
data: {
name: _name,
email: _email,
address: _address,
zipcode: _zipcode,
latitude: _latitude,
longitude: _longitude,
},
});
return user;
}
return dbaccess.execute(func);
},
~/nodetest$ curl -X POST -H "Content-Type: application/json" -d '{"id":1, "name": "test12", "email": "aaa@aaa.aaa", "address": "東京都渋谷区代々木3"}' -b "id=a" localhost:3001/user/update
{"id":1,"email":"aaa@aaa.aaa","name":"test12","zipcode":"1510053","address":"東京都渋谷区代々木3","latitude":35.683224,"longitude":139.693909}
郵便番号 URL をおかしくしてアクセスできない場合は緯度経度だけでも更新されることを確認する。
export default {
addressFromZipcodeUrl: "https://aaaaa?address=",
latlonFromZipcodeUrl: "https://msearch.gsi.go.jp/address-search/AddressSearch?q=",
}
~/nodetest$ curl -X POST -H "Content-Type: application/json" -d '{"id":1, "name": "test12", "email": "aaa@aaa.aaa", "address": "東京都渋谷区代々木3"}' -b "id=a" localhost:3001/user/update
{"id":1,"email":"aaa@aaa.aaa","name":"test12","zipcode":"","address":"東京都渋谷区代々木3","latitude":35.683224,"longitude":139.693909}
OK。まあ正直言って async / await の使い方がいまいち分かってないが。
認証処理(セッション)
認証チェックは middleware として Filter Chain のようにいれてあるが、中身はいいかなと思っていたがパクリ元6つ目7つ目を参考にやはりやることとする。
~/nodetest$ npm install --save passport passport-local express-session
~/nodetest$ npm install --save-dev @types/passport @types/passport-local @types/express-session
ユーザーテーブルにパスワードカラム追加。
・・・
model User {
id Int @id @default(autoincrement())
email String @unique
password String
name String
zipcode String?
address String?
latitude Float?
longitude Float?
}
パクリ元8つ目を参考にデータ投入するデータに暗号化した「PASSWORD」というパスワードを設定する。
~/nodetest$ npm install --save bcrypt
~/nodetest$ npm install --save-dev @types/bcrypt
import { PrismaClient } from '@prisma/client';
var bcrypt = require('bcrypt');
const prisma = new PrismaClient();
const saltRounds = 10;
const password = 'PASSWORD';
async function main() {
const alice = await prisma.user.upsert({
where: { email: 'alice@prisma.io' },
update: {},
create: {
email: 'alice@prisma.io',
password: bcrypt.hashSync(password, saltRounds),
name: 'Alice',
},
});
console.log({ alice });
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
~/nodetest$ npx prisma db push
~/nodetest$ npx prisma migrate dev
・・・
✔ Enter a name for the new migration: … modify_user_add_column_password
・・・
~/nodetest$ npx prisma migrate reset
・・・
✔ Are you sure you want to reset your database? All data will be lost. … yes
・・・
まずはセッション用のローカルストラテジーを設定する。このローカルというのは外部の認証を使用せず内部のDB等を利用した認証を表すらしい。
import passport from 'passport'
import { Strategy as LocalStrategy } from "passport-local";
import bcrypt from 'bcrypt';
import { User } from '@prisma/client';
import userModel from '../models/userModel'
declare global {
namespace Express {
interface User {
email: string;
}
}
}
export const localStrategyWithSessionAuthenticate = () =>{
passport.use(new LocalStrategy({
usernameField: 'email',
passwordField: 'password',
}, (email:string, password: string, done) => {
process.nextTick(() => {
userModel.getByEmail(email)
.then(async (user: User) => {
if (!user) {
return done(null, false, {message: "Invalid User"});
} else if (await bcrypt.compare(password, user.password)) {
return done(null, user);
} else {
return done(null, false, {message: "Invalid User"});
}
})
.catch((err) => {
console.error(err);
return done(null, false, {message: err.toString()})
});
})
}))
passport.serializeUser((user, done) => {
done(null, user.email);
})
passport.deserializeUser(async (email: string, done) => {
await userModel.getByEmail(email)
.then((user: User) => {
done(null, user);
})
.catch ((err) => {
done(err, null);
});
})
}
ここの localStrategyWithSessionAuthenticate の関数を呼んでおかなければいけない、ということがはじめよく分かっていなかった。なので index.ts で呼ぶことにした。今回ユーザー名の代わりにメールアドレスを使用することにしたので上部 declare global 部分を追加してある。これはパクリ元9つ目情報。パクリ元同様 config フォルダに整理しておいたが微妙なトコロか。
localStrategyWithSessionAuthenticate 関数内には3つの関数があるが、1つ目がリクエストされたメールアドレスとパスワードを DB のユーザーデータと比較してOKの場合はリクエストの isAuthenticated メソッドに true を設定する。login 以外のAPIではこれをチェックすることになる。2つ目は認証が通った時にメールアドレスを(今回ストレージを指定していないのでメモリ内の)セッション情報に設定する。セッションID がレスポンスのクッキーとして返されることになる。3つ目はリクエストのクッキーにセッションIDがあった場合、セッションからメールアドレスを復元してユーザー情報を取得して req.user として使用できるようになる便利機能。
セッションの設定や Passport の初期化等は index.ts に追加。
・・・
import passport from 'passport'
import session from 'express-session'
import {localStrategyWithSessionAuthenticate} from './config/localStrategyWithSessionAuthenticate';
・・・
// session / local authentication
app.use(session({
secret: process.env.SESSION_SECRET || 'session_secret',
resave: false,
saveUninitialized: false
}));
app.use(passport.initialize());
app.use(passport.session());
localStrategyWithSessionAuthenticate();
・・・
// routing
ログイン(POST)時に設定済みのセッション用ローカルストラテジーを呼び出すミドルウェアを設定する。
・・・
import passport from 'passport'
import type { Request, Response, NextFunction } from 'express';
・・・
router.post("/login",
passport.authenticate("local"),
(req:Request,res:Response)=>{res.json("OK")}
);
認証チェックのミドルウェアは以下のようになる。
・・・
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
if (req.isAuthenticated()) {
next()
} else {
next(authenticationError)
}
}
ローカルストラテジー内でメールアドレスからユーザー情報を取得する箇所があるが、その部分を追加する。
・・・
},
getByEmail: async (_email: string) => {
const func = async () => {
return await prisma.user.findUnique({
where: {
email: _email,
},
});
}
return dbaccess.execute(func);
},
curl で試してみる。
~/nodetest$ curl localhost:3001/user/list
{"result":"failure","message":"authentication error"}
~/nodetest$ curl -X POST -H "Content-Type: application/json" -d '{"email": "alice@prisma.io", "password": "PASSWORD"}' localhost:3001/user/login --dump-header -
HTTP/1.1 200 OK
・・・
Set-Cookie: connect.sid=s%3Ach52IqgImZofyUqbG9MPzc0tjU13aPUV.pAkhjsfI8APtspVlh%2F0cMBGCuWDOMrSBl6GW0bpKN%2FM; Path=/; HttpOnly
・・・
返ってきたクッキーに設定されたセッションIDをリクエストに指定する。
~/nodetest$ curl -b "connect.sid=s%3Ach52IqgImZofyUqbG9MPzc0tjU13aPUV.pAkhjsfI8APtspVlh%2F0cMBGCuWDOMrSBl6GW0bpKN%2FM" localhost:3001/user/list
[{"id":1,"email":"alice@prisma.io","password":"・・・","name":"Alice","zipcode":null,"address":null,"latitude":null,"longitude":null}]
見事に認証をクリアして処理が動いた。コンテナ起動する場合はセッションを Redis のような KVS に格納することになる。
認証処理(トークン)
セッションでなく、クッキーに格納する認証方法もあるのでパクリ元10個目を参考にやってみる。
~/nodetest$ npm install --save passport-jwt
~/nodetest$ npm install --save-dev @types/passport-jwt
トークン用のローカルストラテジーを設定する。
import passport from 'passport'
import { Strategy as LocalStrategy } from "passport-local";
import bcrypt from 'bcrypt';
import {
Strategy as JWTStrategy,
ExtractJwt,
StrategyOptions,
} from "passport-jwt";
import { User } from '@prisma/client';
import userModel from '../models/userModel'
declare global {
namespace Express {
interface User {
email: string;
}
}
}
export const localStrategyWithTokenAuthenticate = () =>{
passport.use(new LocalStrategy({
usernameField: 'email',
passwordField: 'password',
session: false,
}, (email:string, password: string, done) => {
process.nextTick(() => {
userModel.getByEmail(email)
.then(async (user: User) => {
if (!user) {
return done(null, false, {message: "Invalid User"});
} else if (await bcrypt.compare(password, user.password)) {
return done(null, user);
} else {
return done(null, false, {message: "Invalid User"});
}
})
.catch((err) => {
console.error(err);
return done(null, false, {message: err.toString()})
});
})
}))
const opts: StrategyOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET || 'jwt_secret',
};
passport.use(
new JWTStrategy(opts, (jwt_payload: any, done: any) => {
done(null, jwt_payload);
})
);
}
セッション用のローカルストラテジーとほとんど同じで、localStrategyWithTokenAuthenticate 関数内には2つの関数があるが、1つ目がリクエストされたメールアドレスとパスワードを DB のユーザーデータと比較して、OKの場合は次の処理に回す。2つ目はリクエストのAuthorizationヘッダに Bearer トークンがあった場合、そのチェックを行う処理。
セッションからの切り替え等 index.ts を修正。
・・・
// 認証切り替え import {localStrategyWithSessionAuthenticate} from './config/localStrategyWithSessionAuthenticate';
import {localStrategyWithTokenAuthenticate} from './config/localStrategyWithTokenAuthenticate';// 認証切り替え
// 認証切り替え import session from 'express-session'
・・・
// session / local authentication
/* 認証切り替え
app.use(session({
secret: process.env.SESSION_SECRET || 'session_secret',
resave: false,
saveUninitialized: false
}));*/
app.use(passport.initialize());
// 認証切り替え app.use(passport.session());
// 認証切り替え localStrategyWithSessionAuthenticate();
localStrategyWithTokenAuthenticate();// 認証切り替え
・・・
// routing
ログイン(POST)時に設定済みのトークン用ローカルストラテジーを呼び出すミドルウェアを設定する。また、認証チェックは専用のミドルウェアを設定する。
・・・
import jwt from "jsonwebtoken";// 認証切り替え
・・・
/* 認証切り替え
router.post("/login",
passport.authenticate("local"),
(req:Request,res:Response)=>{res.json("OK")}
);*/
router.post("/login",// 認証切り替え
passport.authenticate("local", { session: false }),
(req:Request, res:Response) => {
const email = req.user?.email;
const payload = { user: req.user?.email };
const token = jwt.sign(payload, (process.env.JWT_SECRET || 'jwt_secret') as string, {
});
res.json({ email, token });
}
);// 認証切り替え
router.get("/list",
// 認証切り替え authenticate,
passport.authenticate("jwt", { session: false }),// 認証切り替え
userController.list
);
curl で試してみる。
~/nodetest$ curl localhost:3001/user/list
Unauthorized
~/nodetest$ curl -X POST -H "Content-Type: application/json" -d '{"email": "alice@prisma.io", "password": "PASSWORD"}' localhost:3001/user/login
{"email":"alice@prisma.io","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWxpY2VAcHJpc21hLmlvIiwiaWF0IjoxNjk4NTA3MzYwfQ.HkByHZ0mmcDrFWs6kYzIZbD2LiOhKGuiA__qd9p-AhA"}
返ってきたトークンをリクエストの Authorizationヘッダに Bearer トークンとして指定する。
~/nodetest$ curl -X GET -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWxpY2VAcHJpc21hLmlvIiwiaWF0IjoxNjk4NTA3MzYwfQ.HkByHZ0mmcDrFWs6kYzIZbD2LiOhKGuiA__qd9p-AhA' localhost:3
001/user/list
[{"id":1,"email":"alice@prisma.io","password":"・・・","name":"Alice","zipcode":null,"address":null,"latitude":null,"longitude":null}]
見事に認証をクリアして処理が動いた。ペイロードにユーザーIDだけを入れるのは理解した。外部認証を使ってアクセストークンやらリフレッシュトークンやら期限やらいろいろ保持しておきたい場合は文字長くなりそうだけどどんどん追加していいんだろうか?ローカルストラテジーでなくなるからまたフローが違うのかもね、
認可
認可としては「API使用可否」と「取得データ範囲」が思いつくのでペイロードに一緒に入れておきたいところ。なので URL ごとの可否を 0/1 で横に並べた文字列と所属グループIDのカンマ区切り連結で十分かな。
“email":"alice@prisma.io", “authorization":"11101111″, “belongs":"010,022,030″
みたいな感じ。この URL は authorization の左から○番目だから、それが1だったら許可。belongs はデータ取得時に where 句に入れてデータを絞る。めんどくさいから実装しないけどこんなんでいいでしょ。
現状のフォルダ構成
nodetest
┣━ config
┃ ┗━ log4js.json
┣━ dist
┣━ log
┃ ┣━ access.yyyymmdd.log
┃ ┗━ app.yyyymmdd.log
┣━ node_modules
┣━ prisma
┃ ┣━ migrations
┃ ┃ ┣━ yyyymmddhhmiss_create_user
┃ ┃ ┃ ┗━ migration.sql
┃ ┃ ┗━ migration_lock.toml
┃ ┣━ dev.db
┃ ┣━ schema.prisma
┃ ┗━ seed.ts
┣━ src
┃ ┣━ config
┃ ┃ ┣━ localStrategyWithSessionAuthenticate.ts
┃ ┃ ┗━ localStrategyWithTokenAuthenticate.ts
┃ ┣━ const
┃ ┃ ┗━ url.ts
┃ ┣━ controllers
┃ ┃ ┗━ userController.ts
┃ ┣━ core
┃ ┃ ┣━ dbaccess.ts
┃ ┃ ┗━ fetch.ts
┃ ┣━ middleware
┃ ┃ ┣━ addResponseHeader.ts
┃ ┃ ┣━ authenticate.ts
┃ ┃ ┣━ checkIpHeader.ts
┃ ┃ ┗━ errorHandler.ts
┃ ┣━ models
┃ ┃ ┣━ addressModel.ts
┃ ┃ ┗━ userModels.ts
┃ ┣━ routes
┃ ┃ ┣━ index.ts
┃ ┃ ┗━ userRoute.ts
┃ ┣━ types
┃ ┃ ┣━ authenticationError.ts
┃ ┃ ┗━ error.ts
┃ ┣━ validators
┃ ┃ ┗━ userUpdateValidator.ts
┃ ┗━ index.ts
┣━ .env
┣━ .env.development
┣━ .gitignore
┣━ package-lock.json
┣━ package.json
┗━ tsconfig.json
雑感
先ははるかに長い。気になる事が出だすとどんどん追加されるので進まん。
ディスカッション
コメント一覧
まだ、コメントがありません