blog.mahoroi.com

RustとFirebase Authenticationでユーザー認証を導入

-

Rust で書かれた web API サーバーに Firebase Authentication を導入する機会があったので、導入手順の備忘録をまとめます。

ユーザー認証が手軽に導入できる Firebase Authentication には公式から提供されている SDK が存在しますが、残念ながら 2020 年 8 月現在ではサポート対象言語に Rust は含まれていません。そこで JWT ライブラリを使って認証トークンの生成・検証を行い、API サーバーに認証機能を導入します。

カスタム認証システム

Firebase Authentication には、Instagram や Spotify などの外部サービスから提供される認証を使ったカスタム認証システムがあります。今回はこのカスタム認証システムを用いた認証の導入になります。

カスタムトークン生成

はじめに、サーバー側ではログイン画面から送られてくるユーザーの user_idpassword (Third Party の場合は authorization code など) をもとにログインユーザーを識別します。ここでは uid を Firebase Authentication のユーザーUID として使います。

src/handers/login.rs
struct User {
  uid: String
}

async fn get_user_profile() -> User {
  User {
    uid: "a1b2c".to_string(),
  }
}

pub async fn post(user_id: &str, password: &str) -> HttpResponse {
  // get user profile from Own or Third Party Service
  let user = get_user_profile(user_id, user_password).await;

  let prefix = String::from("rust:");
  let uid = prefix + &user.uid;

  // ...
}

つぎの工程は署名付き JWT である Firebase Authentication カスタムトークの作成です。カスタムトーククレームには alguid などのクレームを含む必要があります。このとき必要になる Firebase のクレーム情報は、Firebase コンソール画面から JSON ファイルとしてダウンロードできます。

詳細:https://firebase.google.com/docs/auth/admin/create-custom-tokens?hl=ja

src/firebase/auth/mod.rs
use crate::config::FirebaseConfig;
use crate::firebase;

pub static FIREBASE_AUTHENTICATION_AUDIENCE: &str =
  "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit";

pub fn create_custom_token(
  uid: &str,
) -> Result<String, jsonwebtoken::errors::Error> {
  let firebase_config = FirebaseConfig::new();

  firebase::admin::jwt::encode(
    FIREBASE_AUTHENTICATION_AUDIENCE,
    &firebase_config.private_key_id,
    &firebase_config.private_key,
    &firebase_config.client_email,
    Some(uid.to_string()),
  )
}
src/config.rs
#[derive(Clone, Debug, Deserialize)]
pub struct FirebaseConfig {
  pub project_id: String,
  pub private_key_id: String,
  pub private_key: String,
  pub client_email: String,
  pub client_id: String,
}

impl FirebaseConfig {
  pub fn new() -> FirebaseConfig {
    let file = File::open("path/to/firebase-adminsdk.json").unwrap();
    let reader = BufReader::new(file);
    let config: FirebaseConfig = serde_json::from_reader(reader).unwrap();
    config
  }
}

今回は JWT 生成に jsonwebtoken crate を使っています。必要なクレーム情報を付与したのちに Firebase シークレット情報の private_key を使って、署名化された JWT を作成します。

src/firebase/admin/jwt.ru
use jsonwebtoken::{decode_header, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::time;

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
  pub aud: String,
  pub iat: u64,
  pub exp: u64,
  pub iss: String,
  pub sub: String,
  pub uid: Option<String>,
}

impl Claims {
  pub fn new(audience: &str, client_email: &str, uid: Option<String>) -> Claims {
    let now = time::SystemTime::now();
    let iat = now.duration_since(time::UNIX_EPOCH).unwrap().as_secs();
    Claims {
      aud: audience.to_string(),
      iat: iat,
      exp: iat + (60 * 60),
      iss: client_email.to_string(),
      sub: client_email.to_string(),
      uid: uid,
    }
  }

  fn encode_jwt(
    audience: &str,
    private_key_id: &str,
    private_key: &str,
    client_email: &str,
    uid: Option<String>,
  ) -> Result<String, jsonwebtoken::errors::Error> {
    let mut header = Header::new(Algorithm::RS256);
    header.kid = Some(private_key_id.to_string());
    let claims = Claims::new(audience, client_email, uid);
    let pem = str::as_bytes(&private_key);
    let secret_key = EncodingKey::from_rsa_pem(pem);
    match secret_key {
      Ok(key) => jsonwebtoken::encode(&header, &claims, &key),
      Err(err) => Err(err),
    }
  }
}

pub fn encode(
  audience: &str,
  private_key_id: &str,
  private_key: &str,
  client_email: &str,
  uid: Option<String>,
) -> Result<String, jsonwebtoken::errors::Error> {
  Claims::encode_jwt(audience, private_key_id, private_key, client_email, uid)
}

これにて Firebase Authentication カスタムトークの生成が可能になりました。

あとはリクエストハンドラーのレスポンスで生成したカスタムトークンをクライアントに渡します。

src/handers/login.rs
pub async fn post(user_id: &str, password: &str) -> HttpResponse {
  // ...

  let prefix = String::from("rust:");
  let uid = prefix + &user.uid;

  // get custom token for Firebase Authentication
  let firebase_custom_token = firebase::auth::create_custom_token(&uid).unwrap();

  HttpResponse::Ok().json(Response {
    user_id: user.uid,
    custom_token: firebase_custom_token,
  })
}

クライアント側では、渡されたカスタムトークンを使って Firebase Authentication 認証が可能になります。

client.ts
import firebase from 'firebase/app';
import 'firebase/auth';

firebase.auth().onAuthStateChanged((user) => {
  if (user) {
    console.log(user.uid);
    // => a1b2c
  }
});

const customToken = await fetch('https://example.com/api/login', {
  method: 'POST',
  body: JSON.stringify({
    user_id: 'a1b2c',
    password: 'password',
  }),
});

await firebase.auth().signInWithCustomToken(customToken);

トークン検証

認証済みのクライアントがサーバーへ API リクエストするときは firebase.auth().currentUser?.getIdToken() から得られるトークンをリクエストヘッダーに追加します。API サーバーではクライアントから送られてきたトークンの検証を行うことで、認証済みユーザーからのリクエストなのかどうかを判別します。

src/firebase/auth/mod.rs
pub async fn verify_id_token(
  token: &str,
) -> Result<jsonwebtoken::TokenData<firebase::admin::jwt::Claims>, jsonwebtoken::errors::Error> {
  let firebase_config = FirebaseConfig::new();
  firebase::admin::jwt::verify(token, &firebase_config.project_id).await
}

カスタムトーク生成と同様、トークン検証にも jsonwebtoken crate を使用します。しかし jsonwebtoken は Firebase 側が生成するトークンに使われている x509 証明書に対応していないため、JWK を使ったトークン検証を行います。

はじめに jsonwebtoken::decode_header で JWT をデコードしてトークンの kid を取得します。

src/firebase/admin/jwt.rs
pub async fn verify(
  token: &str,
  firebase_config: &FirebaseConfig,
) -> Result<jsonwebtoken::TokenData<Claims>, jsonwebtoken::errors::Error> {
  let kid = match decode_header(token).map(|header| header.kid) {
    Ok(Some(k)) => k,
    Ok(None) => {
      return Err(jsonwebtoken::errors::Error::from(
        jsonwebtoken::errors::ErrorKind::__Nonexhaustive,
      ))
    }
    Err(err) => return Err(err),
  };
}

つぎに、取り出した kid に対応する JWK を Firebase の JWK リストから探します。JWK リストは https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com から取得可能です。

src/firebase/admin/jwt.rs
pub async fn verify(
  token: &str,
  firebase_config: &FirebaseConfig,
) -> Result<jsonwebtoken::TokenData<Claims>, jsonwebtoken::errors::Error> {
  // ...
  // validate: kid
  let jwks = get_firebase_jwks().await.unwrap();
  let jwk = jwks.get(&kid).unwrap();
  };
}

#[derive(Debug, Deserialize, Eq, PartialEq)]
pub struct JWK {
  pub e: String,
  pub alg: String,
  pub kty: String,
  pub kid: String,
  pub n: String,
}

#[derive(Debug, Deserialize)]
struct KeysResponse {
  keys: Vec<JWK>,
}

pub async fn get_firebase_jwks() -> Result<HashMap<String, JWK>, reqwest::Error> {
  let url =
    "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com";
  let resp = reqwest::get(url).await?.json::<KeysResponse>().await?;

  let mut key_map = HashMap::new();
  for key in resp.keys {
    key_map.insert(key.kid.clone(), key);
  }
  Ok(key_map)
}

最後はデコードしたトークンの内容検証を行います。jsonwebtoken::decode の結果により、クライアントのトークンが有効なものかを判別することができます。

src/firebase/admin/jwt.rs
pub async fn verify(
  token: &str,
  firebase_config: &FirebaseConfig,
) -> Result<jsonwebtoken::TokenData<Claims>, jsonwebtoken::errors::Error> {
  // ...
  // validate: alg, iss
  let mut validation = Validation {
    iss: Some("https://securetoken.google.com/".to_string() + project_id),
    ..Validation::new(Algorithm::RS256)
  };
  // validate: aud
  validation.set_audience(&[project_id]);

  let key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e);
  let decoded_token = jsonwebtoken::decode::<Claims>(token, &key, &validation);
  decoded_token
}

actix-web と Firebase Authentication

actix-web でユーザー認証を行う場合は actix-web の FromRequest trait を継承したモデル内でトークン検証を行うと、スムーズにユーザー認証が導入できます。

src/model/user.rs
use actix_web::dev::Payload;
use actix_web::{Error, FromRequest, HttpRequest};
use serde::{Deserialize, Serialize};
use std::future::Future;
use std::pin::Pin;

use crate::error::response::unauthorized;
use crate::firebase;

#[derive(Deserialize, Serialize, Debug)]
pub struct User {
  pub uid: String,
}

impl FromRequest for User {
  type Error = Error;
  type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
  type Config = ();

  fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
    let mut bearer_token: Option<String> = None;

    if let Some(authen_header) = req.headers().get("Authorization") {
      if let Ok(authen_str) = authen_header.to_str() {
        if authen_str.starts_with("bearer") || authen_str.starts_with("Bearer") {
          let token = authen_str[6..authen_str.len()].trim();
          bearer_token = Some(token.to_string());
        }
      }
    }

    match bearer_token {
      Some(token) => Box::pin(async move {
        let decoded = firebase::auth::verify_id_token(&token).await;
        match decoded {
          Ok(token_data) => {
            let uid = token_data.claims.sub;
            Ok(User { uid: uid })
          }
          Err(_) => Err(unauthorized()),
        }
      }),
      _ => Box::pin(async move { Err(unauthorized()) }),
    }
  }
}
src/route.rs
#[get("/api/auth/me")]
async fn api_auth_me(user: models::user::User) -> Result<HttpResponse, Error> {
  Ok(HttpResponse::Ok().json(user))
}

参考