4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-02-04

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

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

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が呼ばれる

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?