#今までの話
2018年の年末休みに、メンバーの教育、
nodeの勉強、Typescriptの勉強でAPIサーバの開発を行った
##Part1 設計編
https://qiita.com/YukiMiyatake/items/2a443d78308b51aa2344
仮想作るものをソーシャルゲームのAPIサーバにした
言語FWは流行りなどで色々と悩んだが、非同期を教えたかったのと、フロントするにもFaaSするにも
JavaScript、nodeは覚えておくべきなのでnode+Expressにした
DBはMongoでdocker-composeを使い、ソースはGitlabで行うことにした
DBやAPIなどとりあえず設計した。
マイクロサービス化もふまえ、テーブル(コレクション)は機能単位でこまかく分割した
##Part2 単純なAPI作成
https://qiita.com/YukiMiyatake/items/30b9a6b8735d8675b0e8
HelloWorld的なものを作る
Typescriptの文法覚えたり、Express、mongooseの設定など
Joliによるバリデーションが非常に便利だ
一度Joliは深く掘り下げたい
##Part3 Typescript化の説明
https://qiita.com/YukiMiyatake/items/43a56b6b4d804de0bd66
JavaScriptをTypescript化するには少しコツがあったけど
覚えてしまえばどうってことない
##Part4 async/awaitの説明
https://qiita.com/YukiMiyatake/items/3cdaff234ca2d49cbd9c
うちのメンバーには、とりあえずここだけ理解してもらえればいいや
ってぐらい大事
非同期プログラムを普通にかくとCallback Hellになるが、それを解決する方法は用意されている
コルーチン、promise/future等
nodeの場合は、async/awaitを使うと大抵の場合、同期とあまり変わらず便利にコードを書くことができる
ただしExpressは現状のバージョンではPromise/futureに対応していないので、例外処理などもふまえ
Expressでの非同期の書き方の注意を調べた
#今回
最低限のログインと実装まで行う事ができた
トークンの有効期限とワンタイムのランダムSaltを実装し、HTTPS通信にすれば大抵の用途には十分と思われる
##認証方針
https://qiita.com/YukiMiyatake/items/4c2162f85fe3c9c203a7
ここでまとめた通り、私の調査では、今回の案件では下記の認証方針で良さそうだ
通信はHTTPSで行なうため、TLSレイヤーで暗号化される
初回登録時はサーバからユーザIDと ランダムパスワードが送られるのでローカルに保存する
パスワードは元々ランダムなので特にハッシュ化は必要ない
まずログインを行なう。ユーザIDとパスワードをサーバに送り、JWTトークンを受け取る
トークンには有効時間とSaltが入っている。
サーバでは通信ごとにランダムなSalt値を生成しDBに保存する
クライアントは前回受け取ったトークンをHTTPヘッダに付与する
サーバでトークンのSaltを確認すれば、トークンは1回しか使えない
##一意なユーザIDの作成
色々な方法はあるが、管理やシャーディングなどを考え、連番をつけることにした
MongoにはAuto incrementフィールドは存在しないが、おそらくロックフリーで実装された安全なインクリメントがあるのでそれを使う
export interface ICounterDocument extends mongoose.Document {
id: String
seq: Number
}
export const schema = new mongoose.Schema({
id: {
type: String,
required: true
},
seq: {
type: Number,
required: true
}
});
//export const Counter = mongoose.model("Counter", schema);
export const Counter = mongoose.model<ICounterDocument>("Counter", schema);
//require('crypto').randomBytes(256).toString('hex')
export async function generateUserId() {
let doc = await Counter.findOneAndUpdate({id: "user_id"}, { $inc: { seq: 1 } }).exec();
if(doc==null) throw("can not generate user_id");
let counter = doc as ICounterDocument;
return (counter.seq);
}
findAndModifyを使いたいがmongooseにはないため
mongooseではfindOneAndUpdateを使う
generateUserId()で、連番の一意のuser_idが生成できる
##登録
登録は簡単だ
先程の generateUserId()を使いユーザIDを作り
今回はTODOだが、ランダムパスワードを作り
DBを作り、ユーザIDとパスワードを返せばいい
実際には、device_idを確認してすでに登録しているユーザーか?を確認する必要がある
export async function register(req: Request, res: Response) {
// TODO: RequestのValidate
// TODO: deviceチェックを行なう(deviceid & OS_Typeはユニーク)
// let result = await Account.find({ device_id: req.params.device_id }, {_id:0}).exec();
// 一意なuser_idを求める
let user_id = Counter.generateUserId();
let account = new Account(req.body);
account.set("user_id", user_id);
// TODO: Ramdom Password作る
account.set("password", "pass");
// req.body["hashed_password"] = bcrypt.hashSync(req.body.password, 10);
// delete req.body.password;
// let val = await Joi.validate(req.body, AccountSchema.insertSchema, { abortEarly: true });
// TODO: statusテーブル等 必要テーブルの作成&エラー処理
req.body.account = account;
return await account.save();
}
##Passport-jwt
認証にはPassportを使うと便利そうだった
まだ細かくPassportを追っかけてないので ふわっと
###LocalStrategy
LocalStrategyを作成する
これはパスワード認証を行なうものだと思われる
usernameField、passwordField に、IDとPasswordに実際に使うフィールド名を設定する
APIサーバなので sessionはfalse
promiseに対応しているかわからないので、とりあえずCallbackで書く
ユーザIDをテーブルから引き、Passwordの一致をみる
doneで結果を返す。
done(null, false) で認証エラー
done(null, hoge) で認証OKでnext()を呼ぶ。hogeの中身はRequestに追加される
done(error) で一般的なエラーを返せば良いらしい
const localLogin = new LocalStrategy({
usernameField: 'user_id',
passwordField: 'password',
session: false,
}, async (user_id: string, password: string, done: any) => {
console.log("lcoalLogin");
let user = await Account.findOne({ "user_id": user_id });
console.log("user:" + user);
// if (!user || !bcrypt.compareSync(password, user.get("password"))) {
if (!user || (password != user.get("password" ))) {
return done(null, false, { error: 'Your login details could not be verified. Please try again.' });
}
user = user.toObject();
//delete user.get("password");
console.log("OK");
done(null, user);
});
###認証
実際に、設定したlocalStrategyを実行するには、下記のようにルータに置くとよさそう
router.route('/login')
.get(passport.authenticate('local', { session: false }), login);
routerにて、passport.authenticateをまず呼ぶ
localを指定しているので localStrategyが呼ばれ
その認証がOKだった場合に next(この場合はlogin)が呼ばれる
###login
認証OKだったときに呼ばれる
export async function login(req: Request, res: Response) {
let token = generateToken({payloadデータ});
return await({"token":token});
}
function generateToken(payload: any) {
return jwt.sign(payload, config.jwtSecret);
}
jwt.signにて、JWT(JWS)のトークンを作成することが出来る
実際はPayloadにユーザID、有効期限とSalt等を埋め込む必要がある
JWTトークンを返却する
###トークン確認
認証必要なAPIはトークンの確認(認可)をする必要がある
HTTPのAuthorization bearerにトークンを置くのが、APIサーバでは最も良いはずだ
const jwtLogin = new JwtStrategy({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.jwtSecret
// TODO: any禁止
}, async (payload: any, done: any) => {
// let user = await User.findOne({"user_id": payload.user_id});
let user = await Account.findOne({"user_id": payload.user_id});
if (!user) {
return done(null, false);
}
user = user.toObject();
// delete user.password;
done(null, user);
});
passport.use(jwtLogin);
ExtractJwt.fromAuthHeaderAsBearerToken()でHTTPヘッダの Authorization bearerからトークンを読み込む
それをBase64デコードし、署名をSecretKeyで確認すれば改ざんを防げる
JWTトークンよりユーザIDを取得している
本来なら、ヘッダの暗号方式、トークンの有効期限やSaltのチェックを行うが今は省略いている
Accountコレクションよりユーザを引いてくる事で、ユーザIDの妥当性とみなしている
データに問題がない場合は doneでnext()を呼ぶ
###認証API
認証が必要なAPIには上記のトークン確認を行う
router.route('/status')
.get(passport.authenticate('jwt', { session: false }), status_read)
このように、APIの前に passport.authenticateを呼ぶ
今回は ストラテジにjwtを指定したので、先程のJWT認証が呼ばれる
認証OKのときのみ、APIが呼ばれる