Edited at

年末休みにnodeでAPIサーバを勉強した復習 最終回〜part5 ログイン認証をPassportで


今までの話

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フィールドは存在しないが、おそらくロックフリーで実装された安全なインクリメントがあるのでそれを使う

https://qiita.com/nwtgck/items/c3828aa4c7ca6b34edab


counter.model.ts

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を使う

https://docs.mongodb.com/manual/reference/method/db.collection.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が呼ばれる