Redisでセッション管理を行うRust製のWebサーバ 🦀

<div>
  <img src="https://img.shields.io/badge/Rust-1.48.0-000000.svg?logo=Rust&style=flat-square">
  <img src="https://img.shields.io/badge/Node.js-14.15.0-339933.svg?logo=Node.js&style=flat-square">
  <img src="https://img.shields.io/badge/Nuxt.js-2.14.6-00c58e.svg?logo=Nuxt.js&style=flat-square">
  <img src="https://img.shields.io/badge/Docker-19.03.13-2496ed.svg?logo=Docker&style=flat-square">
  <img src="https://img.shields.io/badge/Docker Compose-1.27.4-2496ed.svg?logo=Docker&style=flat-square">
</div>

[ISL Advent Calendar 2020](https://adventar.org/calendars/5538) の9日目の記事です。

本記事ではセッション管理を NoSQL の Redis で行うメリットについて説明し、Redis にセッション管理を任せた Web サーバをプログラミング言語 Rust で実装したいと思っております。
ソースコードは https://github.com/pyama2000/advent2020 にあります。

# 1. セッション管理

HTTP はステートレスなので状態を持つことができないため、ログイン状態やカートに追加された商品を保持することができません。そこで、ウェブストレージ（LocalStrage, SessionStrage）や Cookie などで状態を保持します。また、React.js や Vue.js などのフレームワークには状態を管理する機能があります。
この記事では Cookie を使った2種類のセッション（状態）管理について説明します。

## 1.1 Cookie にセッションデータを保持する

![cookie.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227668/3ed3b259-e05f-33f6-eecc-09bcbad7d539.png)

セッションデータを Cookie に保存することで、状態を保持することができます。Cookie はリクエスト / レスポンス時に送信されるため、相互にセッションデータを参照できます。
ただ、Cookie に保存できるデータ量が 4096 バイトなので多くのデータを保持することができません。また、全てのデータを持っているため攻撃を受けたときにすべての情報が盗み取られる可能性があります。

## 1.2 Redis にセッションデータを保持する

![cookie-redis.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227668/117ac387-4ae1-c4cc-b659-18a145604f77.png)

すべてのセッションデータを Cookie に保存する場合、4096 バイト以上のデータを保存できないというデメリットがありました。そこで、NoSQL の Redis を使ってセッション管理をすることでデータ量を気にせずにデータを保存することができます。

Redis はメモリ上で動作する（インメモリ）キーバリュー型のデータベースです。すべてのデータをメモリ上に格納するため高速なアクセスが可能になります。また、データの有効期限（expire）を設定できるのでセッションが終わったデータを自動で削除することができます。

Redis でセッション管理するとき、サーバではユニークで予測不可能なセッション ID を生成して、Cookie にセッション ID をセットしたレスポンスをクライアントに送信します。Redis のキーをセッション ID にすることでデータを保持することができます。クライアント側はセッション ID しか持っていないため、Cookie を盗み見られても住所や電話番号などの重要な情報を取られることはありません。

この方式のデメリットは、Redis を使用するので何らかの障害によって Redis サーバが停止した場合はデータが消失する可能性があります。また、多くのアクセスがあったときに処理が遅くなる恐れがあります。しかし、Redis にはレプリケーション機能やデータの永続化機能があるためうまく運用することでこれらのデメリットを解消することができます。

# 2. Web サーバの実装 :crab: 

Webフレームワーク `actix-web` を使って、Redis でセッション管理をする Web サーバを実装します。この記事では実装の説明を途中で終えているので、残りの部分は自分で実装するかリポジトリをみてください。

## 2.1 実装する Web サーバの概要

Web サーバの機能と実行に必要な環境変数について説明します。

### 2.1.1 機能

エンドポイントとその機能には以下の通りになっています。

- `POST /login`： セッションに `user_id` のデータがなければ `user_id` を作成して、セッションにセットする
- `PATCH /change`： 新規に `user_id` を作成し、セッションの `user_id` と置き換える
- `POST /add`： セッションの `count` に 1 を追加する
- `DELETE /reset`： セッションの `count` を 0 にする

### 2.1.2 環境変数

Web サーバはデフォルトのままだと `http://0.0.0.0:8000` でリクエストを待ち受ける事になっています。ホストを変更したい場合は環境変数に `HOST` を、ポートを変更したい場合は `PORT` を追加してください。同様に Redis のアドレスを変更するには、環境変数 `REDIS_URL` に Redis の URL を指定してください。

| Key       | Default      |
|:----------|:-------------|
| HOST      | 0.0.0.0      |
| PORT      | 8000         |
| REDIS_URL | 0.0.0.0:6379 |

## 2.2 依存関係

[advent2020/advent2020_api/Cargo.toml](https://github.com/pyama2000/advent2020/blob/4ddee35c456d7545dc3cffbe67cf8984dcf565ad/advent2020_api/Cargo.toml#L9-L18)

```toml:Cargo.toml
[dependencies]
actix-cors = "0.5"
actix-redis = "0.9.1"
actix-session = "0.4"
actix-web = "3"
env_logger = "0.7"
rand = "0.7"
rand_chacha = "0.2"
serde_json = "1"
uuid = { version = "0.8", features = ["serde", "v4"] }
```

`actix-web` はメジャーアップデートで破壊的な変更があるパッケージで、バージョン2の場合は非同期を使用するために `actix-rt` が必要でしたがバージョン3にアップデートされたことで `actix-web` だけで非同期に実行できるようになりました。`actix-web`に限らず、`actix` 関連のパッケージはアップデートによって大幅な変更があるので最新の状況を確認してください。

## 2.3 実装

それでは実際に実装していきたいと思います。

### 2.3.1 サーバを起動する

まず、`GET http://0.0.0.0:8000/` というリクエストがあったら、`Hello, world!` と返す Web サーバを実装します。コードは次の通りです。

```rust:src/main.rs
use std::env;

use actix_web::{middleware::Logger, web, App, Error, HttpServer, Responder};

async fn index() -> Result<impl Responder, Error> {
    Ok("Hello, world!")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    env::set_var("RUST_LOG", "actix_web=debug");
    env_logger::init();

    let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
    let port = env::var("PORT").unwrap_or_else(|_| "8000".to_string());

    HttpServer::new(move || {
        App::new()
            .wrap(Logger::default())
            .route("/", web::get().to(index))
    })
    .bind(format!("{}:{}", host, port))?
    .run()
    .await
}
```

main 関数から見ていくと、main 関数に `actix` を非同期で利用するために `#[actix_web::main]` マクロを記述します。
main 関数の中では、まず `actix_web` のログを出力するための設定を記述しています。ログレベルは `debug`, `error`, `info`, `warn`, `trace` と5つあるので必要に応じて変更してください。
次に、Web サーバを起動に必要なホストとポート情報を、環境変数に指定されていたら環境変数から取得し、環境変数になかった場合は、ホストを `0.0.0.0`、 ポートを `8000` に指定します。
最後に、ログを出力するミドルウェアと`GET http://0.0.0.0:8000/`のリクエストを処理する関数を持った `App`インスタンスを実行する、`HttpServer` を立ち上げます。

`cargo run` で 実行ファイルを起動したら、`curl` コマンドで `http://0.0.0.0:8000/` にリクエストを送ってみましょう。次のような結果が返ってきたら成功です。

```
$ curl -v http://0.0.0.0:8000/
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> GET / HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< content-length: 13
< content-type: text/plain; charset=utf-8
< date: Sun, 29 Nov 2020 07:39:57 GMT
<
* Connection #0 to host 0.0.0.0 left intact
Hello, world!* Closing connection 0

```

### 2.3.2 Redis を使ったセッション管理

いよいよ Redis を使ったセッション管理機能を実装します。変更点は次の通りです。

```diff:src/main.rs
  use std::env;
  

+ use actix_redis::RedisSession;
+ use actix_session::Session;
  use actix_web::{middleware::Logger, web, App, Error, HttpServer, Responder};


- async fn index() -> Result<impl Responder, Error> {
+ async fn index(_session: Session) -> Result<impl Responder, Error> {
      Ok("Hello, world!")
  }
  
  #[actix_web::main]
  async fn main() -> std::io::Result<()> {
-     env::set_var("RUST_LOG", "actix_web=debug");
+     env::set_var("RUST_LOG", "actix_web=debug,actix_redis=info");
      env_logger::init();
  
      let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
      let port = env::var("PORT").unwrap_or_else(|_| "8000".to_string());
+     let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "0.0.0.0:6379".to_string());
  
      HttpServer::new(move || {
          App::new()
              .wrap(Logger::default())
+             .wrap(RedisSession::new(&redis_url, &[0u8; 32]))
              .route("/", web::get().to(index))
      })
      .bind(format!("{}:{}", host, port))?
      .run()
      .await
  }
```

main 関数では、まず、actix で Redis を利用するための `actix_redis` パッケージのログを出力するように変更しています。
次に、ホストやポートと同様に、 Redis のアドレスを環境変数で指定されていたら取得し、環境変数になかった場合は `0.0.0.0:6379` と指定します。
最後に `App` インスタンスにミドルウェアとして `RedisSession` インスタンスを追加しています。

index 関数でセッションを利用したいため、引数に `actix_session::Session` を追加しましたが、index 関数ではまだセッションを使わないため、変数名を `_session` としています。

<br>

`RedisSession` インスタンスを初期化する際に、0が32個並んだ配列を渡していますが、ランダムな配列を渡したほうがいいので、そのように変更します。このとき、暗号論的疑似乱数生成器（CSPRNG）によって再現不可能な乱数の配列を作成します（※ ここまでする必要はないですが）。

```diff:src/main.rs
  use std::env;
  
  use actix_redis::RedisSession;
  use actix_session::Session;
  use actix_web::{middleware::Logger, web, App, Error, HttpServer, Responder};
+ use rand::prelude::*;
+ use rand_chacha::ChaCha20Rng;
  
  async fn index(_session: Session) -> Result<impl Responder, Error> {
      Ok("Hello, world!")
  }
  
  #[actix_web::main]
  async fn main() -> std::io::Result<()> {
      env::set_var("RUST_LOG", "actix_web=debug,actix_redis=info");
      env_logger::init();
  
      let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
      let port = env::var("PORT").unwrap_or_else(|_| "8000".to_string());
      let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "0.0.0.0:6379".to_string());
  

+     let mut csp_rng = ChaCha20Rng::from_entropy();
+     let mut data = [0u8; 32];
+     csp_rng.fill_bytes(&mut data);
  
      HttpServer::new(move || {
          App::new()
              .wrap(Logger::default())
-             .wrap(RedisSession::new(&redis_url, &[0u8; 32]))
+             .wrap(RedisSession::new(&redis_url, &data))
              .route("/", web::get().to(index))
      })
      .bind(format!("{}:{}", host, port))?
      .run()
      .await
  }
```

`curl` で確認すると、ランダムな文字列をもった `actix-session` をクッキーにセットするようにレスポンスが返ってきます。

```
$ curl -v http://0.0.0.0:8000/
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> GET / HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< content-length: 13
< content-type: text/plain; charset=utf-8
< set-cookie: actix-session=kc3Tt2AXplWf0jmdi8PbkOB7TgZbZusuh/canRMiK8A=XAKamRLydJ6v4HGYGt66ds52rKk0yB5K; HttpOnly; Path=/; Max-Age=604800
< date: Sun, 29 Nov 2020 07:55:29 GMT
<
* Connection #0 to host 0.0.0.0 left intact
Hello, world!* Closing connection 0
```

### 2.3.3 セッションにデータをセットする

セッションストレージとして Redis を使用する実装ができたので、次は `user_id` をセッションデータに追加する実装を行います。

変更点は次のようになっていて、`GET http://0.0.0.0:8000/` の代わりに、ログイン処理を行うエンドポイント `POST http://0.0.0.0:8000/login` に変更します。

```diff:src/main.rs
  use std::env;
  
  use actix_redis::RedisSession;
  use actix_session::Session;
- use actix_web::{middleware::Logger, web, App, Error, HttpServer, Responder};
+ use actix_web::{middleware::Logger, web, App, Error, HttpResponse, HttpServer, Responder};
  use rand::prelude::*;
  use rand_chacha::ChaCha20Rng;
+ use serde_json::json;
+ use uuid::Uuid;
  

- async fn index(_session: Session) -> Result<impl Responder, Error> {
-     Ok("Hello, world!")
+ async fn login(session: Session) -> Result<impl Responder, Error> {
+     let json = match session.get::<Uuid>("user_id")? {
+         Some(user_id) => json!({ "user_id": &user_id }),
+         None => {
+             let user_id = Uuid::new_v4();
+             session.set("user_id", &user_id)?;
+ 
+             json!({ "user_id": &user_id })
+         }
+     };
+ 
+     Ok(HttpResponse::Ok().json(&json))
  }
  
  #[actix_web::main]
  async fn main() -> std::io::Result<()> {
      env::set_var("RUST_LOG", "actix_web=debug,actix_redis=info");
      env_logger::init();
  
      let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
      let port = env::var("PORT").unwrap_or_else(|_| "8000".to_string());
      let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "0.0.0.0:6379".to_string());
  
      let mut csp_rng = ChaCha20Rng::from_entropy();
      let mut data = [0u8; 32];
      csp_rng.fill_bytes(&mut data);
  
      HttpServer::new(move || {
          App::new()
              .wrap(Logger::default())
              .wrap(RedisSession::new(&redis_url, &data))
-             .route("/", web::get().to(index))
+             .route("/login", web::post().to(login))
      })
      .bind(format!("{}:{}", host, port))?
      .run()
      .await
  }
```

login 関数ではまず、セッションデータに `user_id` が存在したらそのデータを取得し JSON 形式に変更して変数の値として返します。セッションデータに `user_id` が存在しなかった場合は `user_id` を新たに作成したものをセッションデータにセットし、そのデータを JSON 形式にして変数の値として返しています。
最終的に`user_id` を JSON 形式のデータとして持った変数をレスポンスのデータとして返します。

<br>

`curl`コマンドで `POST http://0.0.0.0:8000/login` にリクエストすると、JSON の形で `user_id` が受け取れていることが確認できます。

```
$ curl -v -X POST http://0.0.0.0:8000/login
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> POST /login HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< content-length: 50
< content-type: application/json
< access-control-allow-origin: *
< access-control-allow-credentials: true
< set-cookie: actix-session=0XMjt9kEglh39LrXtbSQfpSd+o0dyKY5WbkwxlquXwQ=0jkFuRnGJRAfceiGGbzYd6DIRVNxsuAd; HttpOnly; Path=/; Max-Age=604800
< date: Sun, 29 Nov 2020 10:18:45 GMT
<
* Connection #0 to host 0.0.0.0 left intact
{"user_id":"436f3732-77c1-42ea-842e-4845821004e0"}* Closing connection 0
```

<br>

セッションストレージとして利用している Redis にどのようなデータが保存されているか確認するために `redis-cli` を実行します。
`keys *` でセッションキーを確認でき、そのセッションキーから `get <セッションキー>` でセッションデータを確認できます。

```
127.0.0.1:6379> keys *
1) "session:0jkFuRnGJRAfceiGGbzYd6DIRVNxsuAd"

127.0.0.1:6379> get session:0jkFuRnGJRAfceiGGbzYd6DIRVNxsuAd
"{\"user_id\":\"\\\"436f3732-77c1-42ea-842e-4845821004e0\\\"\"}"
```

### 2.3.4 ログインしていない場合は 401 Unauthorized を返す

`PATCH http://0.0.0.0:8000/change` や `POST http://0.0.0.0:8000/add`、`DELETE http://0.0.0.0:8000/reset` はログイン状態のみ実行できるようにしたいので、セッションデータに `user_id` がセットされていない場合は 401 Unauthorized が返るように実装します。
今回は `POST http://0.0.0.0:8000/add` の実装を説明します。

```diff:src/main.rs
  use std::env;
  
  use actix_redis::RedisSession;
  use actix_session::Session;
  use actix_web::{middleware::Logger, web, App, Error, HttpResponse, HttpServer, Responder};
  use rand::prelude::*;
  use rand_chacha::ChaCha20Rng;
  use serde_json::json;
  use uuid::Uuid;
  
  async fn login(session: Session) -> Result<impl Responder, Error> {
      let json = match session.get::<Uuid>("user_id")? {
          Some(user_id) => json!({ "user_id": &user_id }),
          None => {
              let user_id = Uuid::new_v4();
              session.set("user_id", &user_id)?;
  
              json!({ "user_id": &user_id })
          }
      };
  
      Ok(HttpResponse::Ok().json(&json))
  }
  

+ async fn add(session: Session) -> Result<impl Responder, Error> {
+     if session.get::<Uuid>("user_id")?.is_none() {
+         return Ok(HttpResponse::Unauthorized().finish());
+     }
+ 
+     let count = session.get::<u32>("count")?.unwrap_or(0) + 1;
+     session.set("count", &count)?;
+ 
+     Ok(HttpResponse::Ok().json(json!({ "count": &count })))
+ }
  
  #[actix_web::main]
  async fn main() -> std::io::Result<()> {
      env::set_var("RUST_LOG", "actix_web=debug,actix_redis=info");
      env_logger::init();
  
      let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
      let port = env::var("PORT").unwrap_or_else(|_| "8000".to_string());
      let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "0.0.0.0:6379".to_string());
  
      let mut csp_rng = ChaCha20Rng::from_entropy();
      let mut data = [0u8; 32];
      csp_rng.fill_bytes(&mut data);
  
      HttpServer::new(move || {
          App::new()
              .wrap(Logger::default())
              .wrap(RedisSession::new(&redis_url, &data))
              .route("/login", web::post().to(login))
+             .route("/add", web::post().to(add))
      })
      .bind(format!("{}:{}", host, port))?
      .run()
      .await
  }

```

`user_id` がセッションデータにない場合 401 Unauthorized を返す部分を表現したのが、add 関数の最初の3行です。

<br>

`curl` で `POST http://0.0.0.0:8000/add` にリクエストを送ると 401 Unauthorized が返っているのが確認できます。

```
$ curl -v -X POST http://0.0.0.0:8000/add
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> POST /add HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< content-length: 0
< access-control-allow-origin: *
< access-control-allow-credentials: true
< set-cookie: actix-session=FsIFXHGEjeFB1LQwmrYI5GFazBYkhVRszjvcaAcbZ3g=y91fxpmT65XxkdUvkYfzluBYHMcrwtCA; HttpOnly; Path=/; Max-Age=604800
< date: Sun, 29 Nov 2020 08:37:01 GMT
<
* Connection #0 to host 0.0.0.0 left intact
* Closing connection 0
```

<br>

`POST http://0.0.0.0:8000/add` のリクエストを成功させるためには、`POST http://0.0.0.0:8000/login` にリクエストして、レスポンスのクッキー情報をリクエストにセットしたリクエストを `POST http://0.0.0.0:8000/add` に送る必要があります。

```
$ curl -v -X POST http://0.0.0.0:8000/login
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> POST /login HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< content-length: 50
< content-type: application/json
< access-control-allow-origin: *
< access-control-allow-credentials: true
< set-cookie: actix-session=Ul1VoNATeBbsZLOFXoUficMGP2NGte4PdBdDLEY0NAk=6aLlzVKZqc9lBDvoM2NrK6Jrnbf3zCuE; HttpOnly; Path=/; Max-Age=604800
< date: Sun, 29 Nov 2020 08:40:54 GMT
<
* Connection #0 to host 0.0.0.0 left intact
{"user_id":"bb6e25e4-f9aa-4a2b-be5b-f6587967cae3"}* Closing connection 0
```

```
$ curl -v -X POST 0.0.0.0:8000/add --cookie actix-session=Ul1VoNATeBbsZLOFXoUficMGP2NGte4PdBdDLEY0NAk=6aLlzVKZqc9lBDvoM2NrK6Jrnbf3zCuE
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> POST /add HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
> Cookie: actix-session=Ul1VoNATeBbsZLOFXoUficMGP2NGte4PdBdDLEY0NAk=6aLlzVKZqc9lBDvoM2NrK6Jrnbf3zCuE
>
< HTTP/1.1 200 OK
< content-length: 11
< content-type: application/json
< access-control-allow-origin: *
< access-control-allow-credentials: true
< date: Sun, 29 Nov 2020 08:41:19 GMT
<
* Connection #0 to host 0.0.0.0 left intact
{"count":1}* Closing connection 0
```

# 3. おわりに

この記事では、セッション管理に関する説明と Redis を利用したセッション管理機能を備えた Web サーバを Rust で実装する方法を説明しました。Web サーバの実装は途中で終わっていますが、完全版は https://github.com/pyama2000/advent2020/tree/main/advent2020_api にあるのでそちらを確認してください。
また、Web サーバにリクエストを送る Nuxt.js 製のアプリケーションも作成したので、`curl` コマンドではなく GUI で確認したい場合は以下のコマンドで Docker を立ち上げて `http://0.0.0.0:3000/` から操作してください。

```bash
docker-compose up --build
```

---

- [Redisの特徴と基本的な使い方をわかりやすく解説！Redisの用途と活用方法・メリットは？使えるコマンド一覧もご紹介 | A-STAR（エースター）](https://agency-star.co.jp/column/redis/)
- [セッション管理の不備について調べたメモ - Qiita](https://qiita.com/k-akie/items/7b7ac8d20567e6b27fcb)
- [Railsのセッション管理には何が最適か - Qiita](https://qiita.com/shota_matsukawa_ga/items/a21c5cf49a1de6c9561a)
- [Rustで256bitの暗号論的疑似乱数を取得する - Qiita](https://qiita.com/kawadumax/items/fc651939990664584e69)
- [Rust WebフレームワークのACTIXで使う安全なセッション管理機構を考えてみる - Qiita](https://qiita.com/segfo/items/8d992320184fa0a41157)
- [examples/main.rs at master · actix/examples](https://github.com/actix/examples/blob/master/redis-session/src/main.rs)