Rust – Actix Web mongo ユーザー登録 JWT認証
2024.03.24
どもです。
今回は前回の「Rust – Actix Web JWT 認証認可 APIの作成」の続きとなり、またデータベースとしてMongoDBを用いる形となります。
環境設定などに関しては、以前の記事「Rust – Actix Web × MongoDB環境をサクッと起動」
などを参照していただければと思います。
今回のディレクトリ構成は以下の通りです。
jwt_auth_user/src/ ├── api │ ├── mod.rs │ ├── token.rs │ └── user.rs ├── constants.rs ├── error.rs ├── main.rs ├── model │ ├── mod.rs │ ├── response.rs │ ├── token.rs │ └── user.rs ├── repository │ ├── mdb.rs │ └── mod.rs └── utils ├── mod.rs └── token.rs
前回のものを使いまわしたうえ、userを追加、DB操作を行うレイヤーとして、repositoryが追加された形となります。
Actix-web公式サイトのサンプルの「MongoDB」より、リファクタリングを行った形となります。
repositoryレイヤーを追加したMongoDBプロジェクトのソース
https://github.com/rust-game-samples/actix_web_sample/tree/main/mongodb/src
こちらと、前回のものをガッチャンコします。
今回の作成したソースはこちらとなります。
説明は良いので、ソースだけ確認したいって方はこちらをご参照ください。
GitHub
https://github.com/rust-game-samples/actix_web_sample/tree/main/jwt_user_auth
API エンドポイント
それでは、早速作成していきましょう。
行うこととしては以下の項目になります。
- ユーザー登録
- ユーザーログイン
- ユーザー情報取得
- ユーザー情報変更
- ユーザー削除
- リフレッシュトークン
上記に対してのAPIエンドポイントは以下の通りとなります。
POST /register (ユーザー登録)
POST /login (ユーザーログイン)
GET /user/{uuid} (ユーザー情報取得)
PUT /user/{uuid}(ユーザー情報変更)
DELETE /user/{uuid} (ユーザー削除)
それでは、早速各処理について見ていきたいと思います。
DB操作
MongoDBにアクセスし操作する処理はrepository以下のファイルとなります。
main.rsでMDBRepositoryを初期化し、mdb_repoとしてActix-webのweb::Dataとして保持します。
main.rs
let mdb_repo: MDBRepository = MDBRepository::init(COLL_NAME.to_string()).await; App::new() .app_data(web::Data::new(mdb_repo.clone())) .service(register_user)
MDBRepository初期化処理としては、環境変数「MONGODB_URI」があればそちらにアクセスなければ、mongodb://localhost:27017にアクセスし、MongoDB Cliantを作成。
引数「table_name」より、Collectionを取得しstruct MDBRepositoryの保持するかたちとなっています。
pub struct MDBRepository { pub client: Client, pub table_name: String, col: Collection<RegisterUser>, } pub async fn init(table_name: String) -> MDBRepository { let uri = std::env::var("MONGODB_URI").unwrap_or_else(|_| "mongodb://localhost:27017".into()); let client = Client::with_uri_str(uri).await.expect("failed to connect"); create_username_index(&client).await; let col: Collection<RegisterUser> = client.database(DB_NAME).collection(&table_name.clone()); MDBRepository { table_name, client, col, } }
create_username_indexメソッドは、Field「email」をindexとし「COLL_NAME」で定義している「users」のドキュメントを作成しています。
async fn create_username_index(client: &Client) { let options = IndexOptions::builder().unique(true).build(); let model = IndexModel::builder() .keys(doc! { "email": 1 }) .options(options) .build(); client .database(DB_NAME) .collection::<RegisterUser>(COLL_NAME) .create_index(model, None) .await .expect("creating an index should succeed"); }
ユーザー登録
それでは、ユーザー登録のAPIを見ていこうと思います。
ユーザー登録のAPIは、apiのregister_userメソッドで定義しています。
main.rsで、serviceとして登録します。
register_user
App::new() .app_data(web::Data::new(mdb_repo.clone())) .service(register_user)
ユーザー登録APIの処理である、register_userメソッドの全体像は以下の通りとなります。
主な流れとしては、フロントから「email」と「password」のリクエストを受け、登録するユーザを作成し、MongoDBの登録し、成功すればJWTのアクセストークンとリフレッシュトークンをレスポンスとして返却。という流れになります。
api/user.rs
async fn register_user( ddb_repo: Data<MDBRepository>, request: Json<SubmitUserRequest>, ) -> Result<HttpResponse, ServiceError> { let new_user = RegisterUser::new(request.email.clone(), request.password.clone()); let result = ddb_repo.post_user(new_user.clone()).await; let uuid = new_user.get_uuid(); match result { Ok(_) => { let token = create_access_token(uuid.clone())?; let refresh = create_refresh_token(uuid.clone())?; Ok(HttpResponse::Ok().json(ResponseBody::new( MESSAGE_SIGNUP_SUCCESS, CreateTokenResponse { token, refresh_token: refresh, }, ))) } Err(err) => Err(ServiceError::InternalServerError { error_message: err.to_string(), }), } }
フロントから「email」と「password」のリクエストを受けるため、struct SubmitUserRequestを作成。それを元に、RegisterUserのインスタンスを生成。
model/user.rs
pub struct SubmitUserRequest { pub email: String, pub password: String, }
RegisterUser生成には、uuidを発行。first_nameとlast_nameなどのフィールドは取り敢えず空文字登録。
pub struct RegisterUser { pub uuid: String, pub first_name: String, pub last_name: String, pub username: String, pub email: String, pub password: String, }
RegisterUserのインスタンスのnew_userをddb_repoのpost_userメソッドの引数に渡し、ユーザー登録のDB処理を行っていきます。
let result = ddb_repo.post_user(new_user.clone()).await;
ユーザー登録のDB処理である、MDBRepositoryのpost_userメソッドは至ってシンプル。
自分自身が保持するコレクション「users」に対して「insert_one」でMongoDBに登録を行います。成功すればそのままresultを返却。
pub async fn post_user(&self, user: RegisterUser) -> Result<InsertOneResult, ServiceError> { let result = self.col.insert_one(user, None).await; match result { Ok(user_result) => Ok(user_result), Err(_) => Err(ServiceError::CreationFailure { error_message: MESSAGE_SIGNUP_FAILED.to_string(), }), } }
MDBRepositoryのpost_userメソッドから返却されたresultが問題なければ、フロントへのレスポンスには、トークンを含んだレスポンスを返却します。
エラーの場合はそのままエラーを返却。
match result { Ok(_) => { let token = create_access_token(uuid.clone())?; let refresh = create_refresh_token(uuid.clone())?; Ok(HttpResponse::Ok().json(ResponseBody::new( MESSAGE_SIGNUP_SUCCESS, CreateTokenResponse { token, refresh_token: refresh, }, ))) } Err(err) => Err(ServiceError::InternalServerError { error_message: err.to_string(), }), }
create_access_tokenやcreate_refresh_tokenでJWTトークンを作成するのですが、今回はユーザーIDである、uuidもclaimsに含め作成します。
JWTの作成処理などに関しては前回の記事をご参照いただければと思い、今回は割愛いたします。
pub fn create_access_token(uuid: String) -> Result<String, ServiceError> { let claims = create_custom_claims(false, uuid, Duration::from_mins(15)); claims_authenticate(claims) }
これで、返却されたトークンを用いてアクセス制限されているAPIにアクセスができるようになります。
ユーザー登録の流れは以上で、続いてログイン処理を見ていきます。
今回はサンプルなので簡易の実装ではありますが、本番環境等の場合はメール確認、セキュリティ面、排他処理などを適切に行うことが重要となることを認識いただければと思います。
ログイン
続いて、ログイン処理を作成していきます。
APIはlogin_userメソッドとなりますので、登録同様serviceに登録していきます。
.service(login_user)
APIのlogin_userメソッドも登録の流れとほとんど同じで、MDBRepositoryのlogin_userメソッドにemailとpasswordを引数で渡し実行します。
成功すれば、アクセストークンとリフレッシュトークンをレスポンスで返却するところは、先ほど度と同様になります。
api/user.rs
async fn login_user( ddb_repo: Data<MDBRepository>, request: Json<SubmitUserRequest>, ) -> Result<HttpResponse, ServiceError> { let result = ddb_repo.login_user(&request.email, &request.password).await; match result { Ok(user) => { let token = create_access_token(user.get_uuid())?; let refresh = create_refresh_token(user.get_uuid())?; Ok(HttpResponse::Ok().json(ResponseBody::new( MESSAGE_LOGIN_SUCCESS, CreateTokenResponse { token, refresh_token: refresh, }, ))) } Err(err) => Err(ServiceError::InternalServerError { error_message: err.to_string(), }), } }
MDBRepositoryのlogin_userメソッドは、emailをkeyとしてfind_oneで検索し、ハッシュ化されたパスワードの検証を行い、問題なければレスポンス用スキーマを持ったUserデータをJSONで返却します。
repository/mdb.rs
pub async fn login_user(&self, email: &str, password: &str) -> Result<User, ServiceError> { let collection: Collection<RegisterUser> = self.client.database(DB_NAME).collection(&self.table_name); match collection.find_one(doc! {"email": email}, None).await { Ok(Some(user_data)) => { if verify(password, &user_data.password).unwrap() { Ok(User::from_register_data(user_data)) } else { Err(ServiceError::Unauthorized { error_message: MESSAGE_LOGIN_FAILED.to_string(), }) } } Ok(None) => Err(ServiceError::Unauthorized { error_message: MESSAGE_LOGIN_FAILED.to_string(), }), Err(_) => Err(ServiceError::InternalServerError { error_message: "".to_string(), }), } }
ユーザー情報取得
続いて、ユーザー情報取得なのですが、こちらはユーザーがログイン認証を経てアクセストークンを用いてアクセスする認可APIとなります。
なので、事前にログインしアクセストークンを取得し、Requestヘッダーに付与して送信する必要があります。
web::scope("/user") .service(get_user)
URIに付与されたuuidと、アクセストークン内のuuidを検証し一致した場合、そのユーザーの情報を返却する形となっております。
api/user.rs
async fn get_user( ddb_repo: Data<MDBRepository>, uuid: Path<String>, request: HttpRequest, ) -> Result<HttpResponse, ServiceError> { let user_id = uuid.into_inner(); let token = get_token(request)?; let claims = claims_verify_token(&token)?; if claims.custom.refresh { return Err(ServiceError::BadRequest { error_message: MESSAGE_REFRESH_TOKEN_ERROR.to_string(), }); } let sub_uuid = get_sub_uuid(&claims, &user_id)?; let result = ddb_repo.get_user(sub_uuid.clone()).await; match result { Ok(user) => Ok(HttpResponse::Ok().json(ResponseBody::new(MESSAGE_OK, user))), Err(err) => Err(err), } }
get_token、claims_verify_tokenの流れは前回の記事の形となります。
またここでは、アクセストークンのみを許可するので、リフレッシュトークンの場合はエラー処理を入れています。
get_sub_uuidで、claimsに登録されているuuidを取得します。
let sub_uuid = get_sub_uuid(&claims, &user_id)?;
repository側では、認証が通ったuuidが来る想定なので、そちらを元にfind_oneを行いユーザーを検索し、マッチした場合userを返却します。
repository/mdb.rs
pub async fn get_user(&self, uuid: String) -> Result<User, ServiceError> { let collection: Collection<User> = self.client.database(DB_NAME).collection(&self.table_name); match collection.find_one(doc! { "uuid": &uuid }, None).await { Ok(Some(user)) => Ok(user), Ok(None) => Err(ServiceError::BadRequest { error_message: MESSAGE_BAD_REQUEST.to_string(), }), Err(_) => Err(ServiceError::InternalServerError { error_message: MESSAGE_INTERNAL_SERVER_ERROR.to_string(), }), } }
ユーザー情報変更
続いて、ユーザー情報変更処理なります。
APIの処理は以下のとおりです。
#[put("/{uuid}")] async fn update_user( ddb_repo: Data<MDBRepository>, uuid: Path<String>, request: HttpRequest, put_user: Json<PutUserRequest>, ) -> Result<HttpResponse, ServiceError> { let user_id = uuid.into_inner(); let token = get_token(request)?; let claims = claims_verify_token(&token)?; if claims.custom.refresh { return Err(ServiceError::BadRequest { error_message: MESSAGE_REFRESH_TOKEN_ERROR.to_string(), }); } let sub_uuid = get_sub_uuid(&claims, &user_id)?; let new_user = User::from_put(user_id.clone(), put_user); let result = ddb_repo.put_user(sub_uuid.clone(), new_user).await; match result { Ok(user) => Ok(HttpResponse::Ok().json(ResponseBody::new(MESSAGE_OK, user))), Err(err) => Err(err), } }
ユーザーのuuidを取得するget_sub_uuidなどは先程と同じ形となります。
データが更新されたUserの情報をPutUserRequest の構造体として受取り、User::from_putで更新用のユーザーを作成し、アップデートを行う処理となります。
let new_user = User::from_put(user_id.clone(), put_user); let result = ddb_repo.put_user(sub_uuid.clone(), new_user).await;
repository側で、更新するデータを受取り、first_name、last_name、usernameのみ更新する流れとなっています。
作成した後に、PUTではなく、PATCHのが適切だなと思いましたがそのままにしています。
repository/mdb.rs
pub async fn put_user(&self, uuid: String, user: User) -> Result<User, ServiceError> { let collection: Collection<User> = self.client.database(DB_NAME).collection(&self.table_name); let filter = doc! {"uuid": uuid}; let update = doc! { "$set": { "first_name": user.first_name, "last_name": user.last_name, "username": user.username, // "email": user.email }, }; let options = mongodb::options::FindOneAndUpdateOptions::builder() .return_document(mongodb::options::ReturnDocument::After) .build(); match collection .find_one_and_update(filter, update, options) .await { Ok(Some(updated_user)) => Ok(updated_user), Ok(None) => Err(ServiceError::UpdateFailure { error_message: MESSAGE_CAN_NOT_UPDATE_DATA.to_string(), }), Err(_) => Err(ServiceError::InternalServerError { error_message: MESSAGE_INTERNAL_SERVER_ERROR.to_string(), }), } }
ユーザー削除
最後に、ユーザーの削除となります。
ユーザー自身を削除するAPIはちょっと変な感じしますが、とりあえずユーザーのCRUDとして入れて置きました。
APIは、apiのdelete_userメソッドで定義しています。
.service(delete_user),
これまでと、ほぼほぼ同じ形となります。
api/user.rs
#[delete("/{id}")] pub async fn delete_user( ddb_repo: Data<MDBRepository>, uuid: Path<String>, request: HttpRequest, ) -> Result<HttpResponse, ServiceError> { let user_id = uuid.into_inner(); let token = get_token(request)?; let claims = claims_verify_token(&token)?; if claims.custom.refresh { return Err(ServiceError::BadRequest { error_message: MESSAGE_REFRESH_TOKEN_ERROR.to_string(), }); } let sub_uuid = get_sub_uuid(&claims, &user_id)?; let result = ddb_repo.delete_user(sub_uuid.clone()).await; match result { Ok(uuid) => Ok(HttpResponse::Ok().json(ResponseBody::new(MESSAGE_OK, uuid))), Err(err) => Err(err), } }
こちらもアクセストークンのみの認可となりますので、リフレッシュトークンの場合はエラーを返却。
let claims = claims_verify_token(&token)?; if claims.custom.refresh { return Err(ServiceError::BadRequest { error_message: MESSAGE_REFRESH_TOKEN_ERROR.to_string(), }); }
こちらも、これまでの流れのように、uuidを元にユーザーを検索し、削除する処理を行っていて、見つからない場合はエラーを返却しております。
repository/mdb.rs
pub async fn delete_user(&self, uuid: String) -> Result<String, ServiceError> { let collection: Collection<User> = self.client.database(DB_NAME).collection(&self.table_name); let filter = doc! {"uuid": &uuid}; match collection.delete_one(filter, None).await { Ok(delete_result) => { if delete_result.deleted_count == 0 { Err(ServiceError::NotFound { error_message: "User not found".to_string(), }) } else { Ok(uuid) } } Err(_) => Err(ServiceError::InternalServerError { error_message: MESSAGE_INTERNAL_SERVER_ERROR.to_string(), }), } }
リフレッシュトークン
主に、前回の記事で説明していることもあって、今回は割愛させていただきます。
というわけで、駆け足でざっとではありましたが、MongoDBにユーザーを登録し、ログイン認証を経て、APIの認可を行いました。
Rustでサーバー開発も全然ありなのが分かってきましたので、現在実際にサービスを開発している途中ではあります。
よろしければ、Rustでサーバー開発もいかがでしょうか。
ではではぁ。