Angular6 で俺式 KOAN スタック Webアプリを構築する、ほぼ全手順（5）

## 概要
Angular6で「俺式 KOAN スタック」で Web アプリ構築するための個人的な備忘録記事。
概要については「[こちら](https://qiita.com/tomy0610/items/9e948154f9b8f56f28cf)」を参照。
事前に「[ほぼ全手順（4）](https://qiita.com/tomy0610/items/b042290322527c627633)」が完了していることが前提。

今回の内容は「セッション管理機構」をつくる。

## ライブラリを導入

セッション管理ライブラリと型定義ファイルをインストール

```bash
npm i koa-session @types/koa-session
```

## バックエンド側にセッション管理機構を構築

### config にセッション管理用定数を追加

config ディレクトリ配下にある、node-config の各環境用の設定ファイル（default.yaml, development.yaml, production.yaml）の全てに、以下のとおりセッション管理用定数を設定する。

```yaml:configディレクトリの各ファイル
...
  session:
    # 認証 および セッション機能を使用する場合は true, 未使用の場合は false
    active: true
    key: 'koa:sess'
    # セッション消滅期間 => number型指定：消滅時間 (ms) または 'session'指定：Webブラウザを閉じたら消滅
    maxAge: 10000
    # セッションアクセスによる既存セッションの上書き許可
    renew: true

```

今回は、`active` という値を用意し、セッション機能を config にて有効/無効の設定ができる様にしておく。

### セッション管理クラスを作成

以下とおり、セッション管理設定クラスを作成する。

```ts:server/managers/session.manager.ts
import * as Koa from 'koa';
import * as config from 'config';
import * as session from 'koa-session';
import * as KoaRouter from 'koa-router';

/**
 * セッション 管理クラス
 */
export class SessionManager {

  /**
   * セッション管理機能を提供する
   * @param app Koa アプリケ―ション
   */
  public serve(app: Koa): void {
    const conf: {active: boolean; key: string; maxAge: number|'session'; renew: boolean} = config.get('session');

    // セッション有効の場合のみ、セッション設定を行う
    if (true === conf.active) {
      app.keys = ['some secret hurr']; // 暗号化されたクッキーID

      const sessionConfig = {
        key: conf.key, // cookie key (default is koa:sess)
        maxAge: conf.maxAge, // セッション消滅期間：number型指定 (ms) (default is 1 day = 86400000 ms) または 'session'指定
        overwrite: true, // can overwrite or not (default is true)
        httpOnly: true, // クライアント側でクッキーを書き換えられないようにするかどうか (default is true)
        signed: true, // signed or not (default is true)
        rolling: false, // Force a session identifier cookie to be set on every response. (default is false)
        renew: conf.renew // セッション アクセスによる既存セッションの上書きができるかどうか (default is false)
      } as session.opts;
      app.use(session(sessionConfig, app));
    }
  }

  /**
   * セッション情報を取得する
   * @param ctx コンテキスト
   */
  public getSession(ctx: KoaRouter.IRouterContext): session.Session {
    return ctx.session;
  }

  /**
   * セッション情報を生成する
   * @param ctx コンテキスト
   */
  public async createSession(ctx: KoaRouter.IRouterContext, sessionEntity: any): Promise<void> {
    ctx.session = sessionEntity;
    ctx.session.isNew = undefined;
  }

  /**
   * セッション情報を破棄する
   * @param ctx コンテキスト
   */
  public destroySession(ctx: KoaRouter.IRouterContext): void {
    ctx.session = null;
  }

  /** 認証済セッション情報が無い場合のレスポンスを返却する */
  public invalidSessionResponse() {

    return {
      statusCode: 401,
      result: false,
      resultMessage: 'セッション情報がありません。再度ログインしてください。',
    };
  }

}

```

### web-server.ts にセッション管理クラスを組み込む

今作ったセッション設定を、アプリ起動時に読み込むように組み込む。

```ts:server/web-server.ts
...

import * as Koa from 'koa';
import * as koaStatic from 'koa-static';
import * as bodyParser from 'koa-bodyparser';
import * as config from 'config';
import { join } from 'path';
import { MiddlewareRouterManager } from './managers/middleware-router.manager';
import { LogManager } from './managers/log.manager';
import { SessionManager } from './managers/session.manager'; // 追加

const app = new Koa();
const PORT = process.env.PORT || config.get('port');
const clientSrcPath = join(process.cwd(), 'dist/client');

new LogManager().serve();

// セッション管理の提供
new SessionManager().serve(app); // 追加

app.use(bodyParser({
  extendTypes: {
    json: ['application/x-javascript']
  }
}));

...
```

### ルーティング管理クラスにセッション機構を組み込む

以下のとおり、セッション情報を確認するロジックを追加する。


```ts:server/managers/middleware-router.manager.ts
import * as config from 'config'; // 追加
import * as KoaRouter from 'koa-router';

import { Logger } from '../logger';
import { SessionManager } from './session.manager'; // 追加
import { ApiPathManager } from './api-path.manager';

...

  public routes(): KoaRouter.IMiddleware {

...

    for (const api of ApiPathManager.apis) {
      // 各メソッドの第 ２ 引数に Auth　処理を追加する
      if (api.method === 'GET') {
        koaRouter.get(api.path, this.auth(api.path), this.controller(api.process));
      } else if (api.method === 'POST') {
        koaRouter.post(api.path, this.auth(api.path), this.controller(api.process));
      } else if (api.method === 'PUT') {
        koaRouter.put(api.path, this.auth(api.path), this.controller(api.process));
      } else {
        koaRouter.delete(api.path, this.auth(api.path), this.controller(api.process));
      }
    }

    return koaRouter.routes();
  }

  // 修正： コールバックに context を追加し、コントローラ側でセッション格納処理をできるようにする
  private controller(controll: (request: any, context: KoaRouter.IRouterContext) => Promise<any>) {

    return async function (ctx: KoaRouter.IRouterContext) {
　　　　　　　　　　　　...

      const response = await controll(request, ctx); // 修正　(ctx 追加)
      ctx.status = response.statusCode;
      ctx.body = response.body;
    };
  }

  /** ↓↓↓ 以下のメソッドを追加 ↓↓↓ */
  private auth(apiPath: string) {

    return async function (ctx: KoaRouter.IRouterContext, next: () => Promise<any>) {
      const isSessionActive: boolean = config.get('session.active');

      if (isSessionActive) {
        const session = new SessionManager();

        if (apiPath !== '/api/auth' && ctx.session.isNew) {
          // Auth 認証以外の API 呼出でセッションが新規の場合, 認証情報無しと判断してレスポンス返却
          const apiRes = session.invalidSessionResponse();
          Logger.info(apiRes.resultMessage);
          ctx.throw(401, apiRes.resultMessage);
        } else {
          await next();
        }
      } else {
        // セッション機能が無効の場合, 無条件でコントローラ ルートへ遷移
        await next();
      }
    };
  }

}
```

### ログイン処理用のコントローラを作る

`'/api/auth'` でログイン API アクセス時に呼ばれるコントローラを作成。本来はここにロジックあまり書きたくないが、テストのためここに書いてレスポンスする。
そして、ログイン後にログイン済みかチェックするための API のコントローラもここで作っておく。

```ts:server/controllers/auth.controller.ts
import * as KoaRouter from 'koa-router';

import { Logger } from '../logger';
import { SessionManager } from '../managers/session.manager';

/** Auth コントローラ */
export class AuthContoroller {

  /** 認証/認可を行う */
  public async auth(request: any, context: KoaRouter.IRouterContext): Promise<any> {
    Logger.info('[AuthContoroller] #auth');

    let response = {
      statusCode: 401,
      body: { result: false }
    };

    // ユーザIDがパスワードが合ってたら成功とし、セッション新規を生成する
    if (request.userId === 'test01' && request.password === 'ok') {
      // セッションを生成して登録する
      const session = { user: request.userId, createDate: new Date() };
      new SessionManager().createSession(context, session);

      // レスポンスデータを返却
      response = {
        statusCode: 200,
        body: { result: true }
      };
    }

    return response;
  }

  /** セッション情報を取得する (セッション切れの場合はミドルで 401 が返る) */
  public async session(request: any, context: KoaRouter.IRouterContext): Promise<any> {
    const response = {
      statusCode: 200,
      body: { session: new SessionManager().getSession(context) }
    };

    return response;
  }

}
```

ログイン情報がログに残るのはよろしくないので、ログ出力は通ったことがわかるだけに留める。

### ログイン コントローラへのルーティングを追加

以下のとおり、ログインおよびログイン済みチェックのルートを追加する。
インターフェースも、 context を追加した形に修正しておく。

```ts:server/managers/api-path.manager.ts
import * as KoaRouter from 'koa-router'; // 追加
import { AuthContoroller } from '../controllers/auth.controller'; // 追加
import { SampleContoroller } from '../controllers/sample.controller';

interface IApiInfo {
  method: string;
  path: string;
  // 修正： 引数に context を追加
  process: (request: any, context: KoaRouter.IRouterContext) => Promise<any>;
}

export class ApiPathManager {

  public static readonly apis: IApiInfo[] = [
    { method: 'POST', path: '/api/auth', process: new AuthContoroller().auth }, // 追加
    { method: 'GET', path: '/api/session', process: new AuthContoroller().session }, // 追加
    { method: 'GET', path: '/api/sample', process: new SampleContoroller().test },
    { method: 'POST', path: '/api/sample', process: new SampleContoroller().test },
  ];

}
```

以上で、バックエンド側の設定は完了。

## フロントエンド側にログイン画面を作る

### ログイン用のモジュール、コンポーネントを生成

```bash
# client/app/modules に移動
cd client/app/modules

# auth モジュール作成
ng generate m auth

# コンポーネント作成 (-m オプションでモジュール指定)
ng generate c auth -m auth
```

### Auth関連のサービスを生成

これはアプリ全体で使用するサービスになるので、`client/app` の直下に共通サービスとして作っておく

```bash:client/app
# サービス生成
ng generate s auth
```

### Auth モジュールを修正

自動生成されたログイン画面のモジュールを修正。
ログイン入力フォームを作るので、必要なモジュールを追加しておく。

```ts:client/app/modules/auth/auth.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; // 追加
import { AuthComponent } from './auth.component';

@NgModule({
  imports: [
    CommonModule,
    FormsModule // 追加
  ],
  declarations: [AuthComponent]
})
export class AuthModule { }
```

### Auth サービス修正

以下のとおり、ログイン処理、ログイン済みチェック処理、セッションチェック処理を作成する。
ログインチェック処理は、ログイン画面で、有効なセッションが既に存在する場合にスキップする処理。
セッションチェック処理は、ログイン後の各画面で有効なセッションがあるかチェックするための処理。

```ts:client/app/modules/auth/login.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Router } from '@angular/router';

/**
 * Auth 関連サービス
 */
@Injectable({
  providedIn: 'root'
})
export class AuthService {

  constructor(protected http: HttpClient, private router: Router) { }

  async login<Req>(request: Req): Promise<any> {
    let response: any;
    const url = '/api/auth';

    try {
      response = await this.http.post<any>(url, request, {
        headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
      }).toPromise();
    } catch (error) {
      response = { result: false };
    }

    return response;
  }

  /** ログイン済みチェック (ログイン画面専用) */
  async checkLogin(): Promise<void> {
    const url = '/api/session';

    try {
      const httpParams: HttpParams = new HttpParams();

      // ログイン済み かつ セッションが有効な場合, ログイン後の画面に遷移する
      await this.http.get<any>(url, {
        headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
        params: httpParams
      }).toPromise();

      this.router.navigate(['home']);
    } catch (error) {
      // 未ログインの場合, 何もしない
    }
  }

  /** セッションチェック (ログイン画面以外の各コンポーネント用) */
  async checkSession(): Promise<void> {
    const url = '/api/session';

    try {
      const httpParams: HttpParams = new HttpParams();

      // ログイン済み かつ セッションが有効な場合, 何もしない
      await this.http.get<any>(url, {
        headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
        params: httpParams
      }).toPromise();

    } catch (error) {
      // セッション切れ または 不正なアクセス等でセッションが無い場合, アラートを出してログイン画面に戻る
      this.backToLogin();
    }
  }

  backToLogin(): void {
    alert('セッション情報がありません。ログインし直してください。');
    this.router.navigate(['auth']);
  }

}
```

### ログイン画面を作成

#### 処理修正

- ログインボタンを押して、「ユーザID」「パスワード」がコントローラに設定した正解通りならログイン成功し、Home コンポーネントに切り替わるように設定
- 入力が不足していたら入力エラーメッセージを出すように設定
- ログインに失敗したら、失敗メッセージを出すように設定

```ts:client/app/modules/auth/auth.component.ts
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';

import { AuthService } from '../../auth.service';

@Component({
  selector: 'app-auth',
  templateUrl: './auth.component.html',
  styleUrls: ['./auth.component.scss']
})
export class AuthComponent implements OnInit {
  userId = '';
  password = '';
  message = '';

  constructor(private service: AuthService, private router: Router) { }

  ngOnInit() {
    this.service.checkLogin();
  }

  async login () {
    // 初期化
    this.message = '';

    // ユーザID と パスワード が入力されているかチェック
    if (this.userId && this.password) {
      // ログイン実施
      const request = { userId: this.userId, password: this.password };
      const response: any = await this.service.login(request);

      if (response.result) {
        // 成功したら Home 画面へ移動
        this.router.navigate(['home']);
      } else {
        this.message = 'ログインに失敗しました';
      }
    } else {
      this.message = 'ユーザID および パスワード を入力してください';
    }
  }
}
```

#### HTML 修正

- 入力エリアとログインボタンを実装
- ボタン押下で先ほどのログイン処理を実行する

```html:client/app/modules/auth/auth.component.html
<div style="text-align: center;">
  <h2>ログイン画面</h2>

  <p *ngIf="message" style="color: red;">{{ message }}</p>

  <div>
    <label>USER</label>
    <input [(ngModel)]="userId">
  </div>
  <div>
    <label>PASS</label>
    <input [(ngModel)]="password">
  </div>

  <div>
    <button type="button" (click)="login()">ログイン</button>
  </div>

</div>
```

※ パスワード入力欄は、今回記事にするため、あえて入力内容をマスクするタイプ設定を指定していないが、本来は入力内容をマスクする password 用にタイプ設定を指定すべき。

### ルートモジュールに Auth モジュール追加

```ts:client/app/app.module.ts
...

import { AuthModule } from './modules/auth/auth.module'; // 追加

@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
    AuthModule, // 追加
    HomeModule
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }
```

### 初期画面をログイン画面にする

```ts:client/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { AuthComponent } from './modules/auth/auth.component'; // 追加

...

const routes: Routes = [
  {
    path: '',
    redirectTo: 'auth', // 修正
    pathMatch: 'full'
  },
  // 以下を追加
  {
    path: 'auth',
    component: AuthComponent
  },

...

];

...
```

### 他のコンポーネントでセッションチェック機構を追加する

#### Home 画面遷移時にセッションチェックする

```ts:client/app/modules/home/home.component.ts
import { Component, OnInit } from '@angular/core';

import { HomeService } from './home.service';
import { AuthService } from '../../auth.service'; // 追加

...
export class HomeComponent implements OnInit {

  constructor(private service: HomeService, private authService: AuthService) { } // 修正

  // 以下のメソッドを修正
  async ngOnInit() {
    await this.authService.checkSession();
  }
...
}
```

#### Home 画面からの HTTP 通信時にセッション切れの対応を追加する

```ts:client/app/modules/home/home.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { AuthService } from '../../auth.service'; // 追加

@Injectable()
export class HomeService {

  constructor(protected http: HttpClient, protected authService: AuthService) { } // 修正

  async getTest<Req>(request: Req): Promise<any> {
...
    } catch (error) {
      // 追加： 認証エラーの場合、セッション切れと判断しログイン画面に戻る
      if (error.status === 401) {
        await this.authService.backToLogin();
      }

      response = {
        val: 'GET リクエストでエラーが発生しました。',
        error: error
      };
    }

...
  }

  async postTest<Req>(request: Req): Promise<any> {
...
    } catch (error) {
      // 追加： 認証エラーの場合、セッション切れと判断しログイン画面に戻る
      if (error.status === 401) {
        await this.authService.backToLogin();
      }
...
    }

    return response;
  }
}
```

## 確認

- アプリを起動して Web ブラウザで `http://localhost:3000` にアクセスする
以下のようにログイン画面が初期画面となる

<img width="1552" alt="セッション機構_01.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/7072bed3-8889-c2f8-71f9-2d8de59bca70.png">

次に、未入力で「ログイン」ボタンを押下し、入力エラーを起こしてみる

<img width="1552" alt="セッション機構_02.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/270b4989-a87c-51bb-e5fb-7938cbda5cc6.png">

次に、不正解の内容を入力して「ログイン」ボタンを押下し、認証エラーを起こしてみる

<img width="1552" alt="セッション機構_03.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/4a376887-f839-94d1-7b18-73fc78342720.png">

- ログイン画面で正常ログインする
コントローラで書いた、正常にログインができる内容を入力する

<img width="1552" alt="セッション機構_04.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/045efed4-4622-921c-8ee6-fc5725f861bc.png">

成功すると、以下のように Home コンポーネントに自動的に画面遷移する

<img width="1552" alt="セッション機構_05.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/c49037fe-c425-98ca-fdf3-16735bade24f.png">

- セッションの有効期限の更新が効いているか確認
 - 現在 `/config/default.yaml` の `session.maxAge` を 10000 (ms) ＝ 10 秒に設定しているため、以下の操作をログイン後 10 秒以内に行う
 - URLを `http://localhost:3000` にして再度アクセスしても、ログイン画面をスキップして Home 画面になれば OK.
 - Home 画面のボタンを押下しても、特に今までと変化なければ OK. 10 秒以内にボタンを押下し直せば、セッションは継続する

- セッションの有効期限切れが検知できているか確認
ログインまたは Home 画面のボタン押下後、10 秒以上経過させ、その後に再度いずれかのボタンを押下した時に、以下のようにアラートが出て、その後ログイン画面に戻れば成功。

<img width="1552" alt="セッション機構_06.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/8f7586ca-9915-aa8b-81e8-24e1a401447e.png">

これで、セッション管理のベースは完成した。

## この後やること

過去の記事を含め、おおよその Web アプリは完成した。あとやることといえば、以下のような細かいケアをしていくと良い。

- 定数化できるものは定数にして扱いやすくする
- HTTP 通信処理を共通部品化して、冗長な書き方をシンプルにする
- クラサバ通信のリクエスト/レスポンスをクラス化して利用するようにする
- セッションIDを表示する機構を入れてログに表示する
- コントローラクラスのロジックをビジネスロジックに切り出し、コントローラをシンプルにする
- DB や Web API 等の外部接続クラスを作成し、外部のデータとやりとりできるようにする
- 開発モードでサーバ起動 （HMR） ができるようなミドルウェアを導入して開発効率を上げる

この辺りをやっていくと、より洗練されたスケルトンになっていくと思います。