ソースを参照

feat(auth-steam): server-side auth

Bryan Lee 1 年間 前
コミット
e863ca4fe3

+ 2 - 0
authentication/.env.sample

@@ -7,3 +7,5 @@ REFRESH_EXPIRES_IN_DAYS=
 ALLOWED_ORIGINS=http://localhost:8060,https://multiplayer-test.bryanmylee.com
 OAUTH_CLIENT_ID=
 OAUTH_CLIENT_SECRET=
+STEAM_APP_ID=
+STEAM_WEB_API_KEY=

+ 11 - 0
authentication/README.md

@@ -37,3 +37,14 @@ Refer to [`diesel-cli`](https://crates.io/crates/diesel_cli) for usage documenta
 ### Google Play Games Services
 
 Setting up Google Play Games on the server requires a dedicated [OAuth 2.0 Client ID](https://console.cloud.google.com/apis/credentials), separate from the Android client or the Web client. The server's client type should be "Web application".
+
+### Steam
+
+Using Steam's Web API requires a [Steamworks Web API publisher authentication key](https://partner.steamgames.com/doc/webapi_overview/auth).
+
+To create a Publisher Web API key:
+
+1. As a user with administrative rights in your Steamworks account, first visit your groups list by going to Users & Permissions, then Manage Groups.
+2. From the list of groups, select or create a group that contains the App IDs for which you wish to have access with the WebAPI key.
+3. Then click into that group to view the users and applications in that group.
+4. If you have administrative permissions, you should then see the option to "Create WebAPI Key" on the right-hand side. Or you should see the key listed if it has already been created.

+ 3 - 1
authentication/src/auth/mod.rs

@@ -4,6 +4,7 @@ pub mod oauth2;
 pub mod play_games;
 pub mod provider;
 pub mod refresh;
+pub mod steam;
 pub mod token;
 
 use crate::auth::identity::{Identity, IdentityConfig};
@@ -22,7 +23,8 @@ pub fn config_service(cfg: &mut web::ServiceConfig) {
         .service(refresh::refresh)
         .service(web::scope("/oauth2").configure(oauth2::config_service))
         .service(web::scope("/game-center").configure(game_center::config_service))
-        .service(web::scope("/play-games").configure(play_games::config_service));
+        .service(web::scope("/play-games").configure(play_games::config_service))
+        .service(web::scope("/steam").configure(steam::config_service));
 }
 
 #[post("/sign-out/")]

+ 19 - 0
authentication/src/auth/steam/mod.rs

@@ -0,0 +1,19 @@
+mod sign_in;
+mod steam_api;
+
+use self::steam_api::{
+    user::{RealSteamUserService, SteamUserService},
+    user_auth::{RealSteamUserAuthService, SteamUserAuthService},
+};
+use actix_web::web;
+use std::sync::Arc;
+
+pub fn config_service(cfg: &mut web::ServiceConfig) {
+    let steam_user_service =
+        web::Data::from(Arc::new(RealSteamUserService) as Arc<dyn SteamUserService>);
+    let steam_user_auth_service =
+        web::Data::from(Arc::new(RealSteamUserAuthService) as Arc<dyn SteamUserAuthService>);
+    cfg.app_data(steam_user_service)
+        .app_data(steam_user_auth_service)
+        .service(sign_in::sign_in);
+}

+ 68 - 0
authentication/src/auth/steam/sign_in.rs

@@ -0,0 +1,68 @@
+use crate::auth::identity::IdentityConfig;
+use crate::auth::provider::{AuthProvider, AuthProviderChangeset, AuthProviderType};
+use crate::auth::steam::steam_api::{user::SteamUserService, user_auth::SteamUserAuthService};
+use crate::auth::{create_new_user, generate_sign_in_success_response};
+use crate::db::DbPool;
+use crate::schema;
+use crate::user::{User, UserWithAuthProviders};
+use actix_web::{error, post, web, HttpResponse};
+use diesel::prelude::*;
+use diesel_async::RunQueryDsl;
+
+#[post("/sign-in/")]
+async fn sign_in(
+    auth_ticket: String,
+    pool: web::Data<DbPool>,
+    identity_config: web::Data<IdentityConfig>,
+    user_service: web::Data<dyn SteamUserService>,
+    user_auth_service: web::Data<dyn SteamUserAuthService>,
+) -> actix_web::Result<HttpResponse> {
+    let user_params = user_auth_service
+        .authenticate_user_ticket(&auth_ticket)
+        .await?;
+
+    let user_info = user_service
+        .get_player_summaries(&[&user_params.steam_id])
+        .await?;
+    let user_info = user_info
+        .get(0)
+        .expect(&format!("No user found with id {}", user_params.steam_id));
+
+    let mut conn = pool.get().await.map_err(error::ErrorInternalServerError)?;
+
+    let provider_changeset: AuthProviderChangeset = user_info.into();
+    let matching_provider: Option<AuthProvider> = diesel::update(schema::auth_provider::table)
+        .filter(schema::auth_provider::provider_id.eq(&user_info.steam_id))
+        .filter(schema::auth_provider::provider_type.eq(AuthProviderType::Steam))
+        .set(&provider_changeset)
+        .get_result(&mut conn)
+        .await
+        .optional()
+        .map_err(error::ErrorInternalServerError)?;
+
+    if let Some(matching_provider) = matching_provider {
+        let user: User = schema::user::table
+            .filter(schema::user::id.eq(&matching_provider.user_id))
+            .first(&mut conn)
+            .await
+            .map_err(error::ErrorInternalServerError)?;
+
+        let providers = user
+            .get_providers(&mut conn)
+            .await
+            .map_err(error::ErrorInternalServerError)?;
+
+        return generate_sign_in_success_response(
+            &mut conn,
+            UserWithAuthProviders { user, providers },
+            &identity_config,
+        )
+        .await;
+    }
+
+    let new_user = create_new_user(&mut conn, user_info)
+        .await
+        .map_err(error::ErrorInternalServerError)?;
+
+    generate_sign_in_success_response(&mut conn, new_user, &identity_config).await
+}

+ 18 - 0
authentication/src/auth/steam/steam_api/mod.rs

@@ -0,0 +1,18 @@
+/*
+ * An interface for the Steamworks Web API.
+ *
+ * Refer to https://partner.steamgames.com/doc/webapi_overview.
+ */
+
+use crate::config::{get_steam_config, SteamConfig};
+
+pub mod user;
+pub mod user_auth;
+
+const URI: &'static str = "https://partner.steam-api.com";
+
+lazy_static::lazy_static! {
+  static ref STEAM_CONFIG: SteamConfig = get_steam_config();
+}
+
+const AUTH_SERVER_STEAM_IDENTITY: &'static str = "authentication";

+ 136 - 0
authentication/src/auth/steam/steam_api/user.rs

@@ -0,0 +1,136 @@
+/*
+ * https://partner.steamgames.com/doc/webapi/ISteamUser.
+ */
+
+use super::{STEAM_CONFIG, URI};
+use actix_web::error;
+
+#[async_trait::async_trait]
+pub trait SteamUserService: Sync {
+    /// https://partner.steamgames.com/doc/webapi/ISteamUser#GetPlayerSummaries
+    ///
+    /// # Arguments
+    ///
+    /// * `steam_ids` - A list of steam IDs (max of 100).
+    async fn get_player_summaries(
+        &self,
+        steam_ids: &[&str],
+    ) -> Result<Vec<get_player_summaries::Player>, error::Error> {
+        let client = reqwest::Client::new();
+
+        let resp = client
+            .get(format!("{URI}/ISteamUser/GetPlayerSummaries/v2/"))
+            .query(&[
+                ("key", STEAM_CONFIG.web_api_key.to_owned()),
+                ("steamids", steam_ids.join(",")),
+            ])
+            .send()
+            .await
+            .map_err(error::ErrorInternalServerError)?;
+
+        let body: get_player_summaries::Body =
+            resp.json().await.map_err(error::ErrorInternalServerError)?;
+
+        match body.response {
+            get_player_summaries::Response::Players(players) => Ok(players),
+            get_player_summaries::Response::Error(err) => {
+                Err(error::ErrorInternalServerError(err.error_description))
+            }
+        }
+    }
+}
+
+pub struct RealSteamUserService;
+
+impl SteamUserService for RealSteamUserService {}
+
+mod get_player_summaries {
+    use serde::{Deserialize, Serialize};
+
+    use crate::{
+        auth::provider::{
+            AuthProviderChangeset, AuthProviderInsert, AuthProviderType, IntoAuthProviderInsert,
+        },
+        user::{User, UserInsert},
+    };
+
+    #[derive(Debug, Clone, Deserialize)]
+    pub struct Body {
+        pub response: Response,
+    }
+
+    #[derive(Debug, Clone, Deserialize)]
+    #[serde(rename_all = "lowercase")]
+    pub enum Response {
+        Players(Vec<Player>),
+        Error(Error),
+    }
+
+    #[derive(Debug, Clone, Serialize, Deserialize)]
+    pub struct Player {
+        #[serde(rename = "steamid")]
+        pub steam_id: String,
+        #[serde(rename = "communityvisibilitystate")]
+        pub community_visibility_state: Option<u8>,
+        #[serde(rename = "profilestate")]
+        pub profile_state: Option<u8>,
+        #[serde(rename = "personaname")]
+        pub persona_name: String,
+        #[serde(rename = "lastlogoff")]
+        pub last_logoff_ts: Option<u64>,
+        #[serde(rename = "profileurl")]
+        pub profile_url: String,
+        pub avatar: String,
+        #[serde(rename = "avatarmedium")]
+        pub avatar_medium: String,
+        #[serde(rename = "avatarfull")]
+        pub avatar_full: String,
+    }
+
+    impl From<&Player> for AuthProviderChangeset {
+        fn from(value: &Player) -> Self {
+            AuthProviderChangeset {
+                order: 0,
+                email: None,
+                email_verified: false,
+                display_name: Some(value.persona_name.clone()),
+                user_name: Some(value.persona_name.clone()),
+                picture_url: Some(value.avatar.clone()),
+                locale: None,
+            }
+        }
+    }
+
+    impl From<&Player> for UserInsert {
+        fn from(value: &Player) -> Self {
+            UserInsert {
+                name: Some(value.persona_name.clone()),
+            }
+        }
+    }
+
+    impl IntoAuthProviderInsert for Player {
+        fn into_provider_insert(&self, user: &User) -> AuthProviderInsert {
+            AuthProviderInsert {
+                user_id: user.id,
+                order: 0,
+                provider_type: AuthProviderType::Steam,
+                provider_id: self.steam_id.clone(),
+                email: None,
+                email_verified: false,
+                display_name: Some(self.persona_name.clone()),
+                user_name: Some(self.persona_name.clone()),
+                picture_url: Some(self.avatar.clone()),
+                locale: None,
+            }
+        }
+    }
+
+    #[derive(Debug, Clone, Serialize, Deserialize)]
+    pub struct Error {
+        #[serde(rename = "errorcode")]
+        pub error_code: u32,
+        #[serde(rename = "errordesc")]
+        pub error_description: String,
+    }
+}

+ 84 - 0
authentication/src/auth/steam/steam_api/user_auth.rs

@@ -0,0 +1,84 @@
+/*
+ * https://partner.steamgames.com/doc/webapi/ISteamUserAuth.
+ */
+
+use super::{AUTH_SERVER_STEAM_IDENTITY, STEAM_CONFIG, URI};
+use actix_web::error;
+
+#[async_trait::async_trait]
+pub trait SteamUserAuthService: Sync {
+    /// https://partner.steamgames.com/doc/webapi/ISteamUserAuth#AuthenticateUserTicket
+    ///
+    /// # Arguments
+    ///
+    /// * `ticket` - The ticket data from GetAuthTicketForWebApi encoded as a hexadecimal string.created.
+    async fn authenticate_user_ticket(
+        &self,
+        ticket: &str,
+    ) -> Result<authenticate_user_ticket::Params, error::Error> {
+        let client = reqwest::Client::new();
+
+        let resp = client
+            .get(format!("{URI}/ISteamUserAuth/AuthenticateUserTicket/v1/"))
+            .query(&[
+                ("key", STEAM_CONFIG.web_api_key.to_owned()),
+                ("appid", STEAM_CONFIG.app_id.to_owned()),
+                ("ticket", ticket.to_owned()),
+                ("identity", AUTH_SERVER_STEAM_IDENTITY.to_owned()),
+            ])
+            .send()
+            .await
+            .map_err(error::ErrorInternalServerError)?;
+
+        let body: authenticate_user_ticket::Body =
+            resp.json().await.map_err(error::ErrorInternalServerError)?;
+
+        match body.response {
+            authenticate_user_ticket::Response::Params(params) => Ok(params),
+            authenticate_user_ticket::Response::Error(err) => {
+                Err(error::ErrorUnauthorized(err.error_description))
+            }
+        }
+    }
+}
+
+pub struct RealSteamUserAuthService;
+
+impl SteamUserAuthService for RealSteamUserAuthService {}
+
+mod authenticate_user_ticket {
+    use serde::{Deserialize, Serialize};
+
+    #[derive(Debug, Clone, Deserialize)]
+    pub struct Body {
+        pub response: Response,
+    }
+
+    #[derive(Debug, Clone, Deserialize)]
+    #[serde(rename_all = "lowercase")]
+    pub enum Response {
+        Params(Params),
+        Error(Error),
+    }
+
+    #[derive(Debug, Clone, Serialize, Deserialize)]
+    pub struct Params {
+        pub result: String,
+        #[serde(rename = "steamid")]
+        pub steam_id: String,
+        #[serde(rename = "ownersteamid")]
+        pub owner_steam_id: String,
+        #[serde(rename = "vacbanned")]
+        pub vac_banned: bool,
+        #[serde(rename = "publisherbanned")]
+        pub publisher_banned: bool,
+    }
+
+    #[derive(Debug, Clone, Serialize, Deserialize)]
+    pub struct Error {
+        #[serde(rename = "errorcode")]
+        pub error_code: u32,
+        #[serde(rename = "errordesc")]
+        pub error_description: String,
+    }
+}

+ 45 - 22
authentication/src/config.rs

@@ -3,48 +3,58 @@ use actix_cors::Cors;
 use chrono::Duration;
 use std::env;
 
-fn get_secret_text_or_file(var: &str) -> String {
+fn get_secret_text_or_file(var: &str) -> Option<String> {
     let secret_text = env::var(var);
-    let secret_file = env::var(format!("{var}_FILE"));
 
     if let Ok(secret_text) = secret_text {
-        secret_text
+        Some(secret_text)
     } else {
-        let secret_file =
-            secret_file.expect(format!("Expected either {var} or {var}_FILE to be set").as_str());
+        use std::fs;
+        let Ok(secret_file) = env::var(format!("{var}_FILE")) else {
+            return None;
+        };
+        fs::read_to_string(secret_file).ok()
+    }
+}
 
+fn get_required_secret_text_or_file(var: &str) -> String {
+    let secret_text = env::var(var);
+
+    if let Ok(secret_text) = secret_text {
+        secret_text
+    } else {
         use std::fs;
+        let secret_file = env::var(format!("{var}_FILE"))
+            .expect(format!("Expected either {var} or {var}_FILE to be set").as_str());
         fs::read_to_string(secret_file)
             .expect(format!("The file at {var}_FILE should contain the secret").as_str())
     }
 }
 
 pub fn get_db_url() -> String {
-    let url = env::var("POSTGRES_URL");
-    if let Ok(url) = url {
+    let url = get_secret_text_or_file("POSTGRES_URL");
+    if let Some(url) = url {
         return url;
     }
 
-    let password = get_secret_text_or_file("POSTGRES_PASSWORD");
-    let user = env::var("POSTGRES_USER").unwrap_or("postgres".to_string());
-    let db = env::var("POSTGRES_DB").unwrap_or("postgres".to_string());
-    let host = env::var("POSTGRES_HOST").unwrap_or("localhost".to_string());
-    let port = env::var("POSTGRES_PORT").unwrap_or("5432".to_string());
+    let password = get_required_secret_text_or_file("POSTGRES_PASSWORD");
+    let user = get_secret_text_or_file("POSTGRES_USER").unwrap_or("postgres".to_string());
+    let db = get_secret_text_or_file("POSTGRES_DB").unwrap_or("postgres".to_string());
+    let host = get_secret_text_or_file("POSTGRES_HOST").unwrap_or("localhost".to_string());
+    let port = get_secret_text_or_file("POSTGRES_PORT").unwrap_or("5432".to_string());
 
     format!("postgres://{user}:{password}@{host}:{port}/{db}")
 }
 
 pub fn get_identity_config() -> auth::identity::IdentityConfig {
-    let secret = get_secret_text_or_file("IDENTITY_SECRET");
-    let expires_in_seconds = env::var("IDENTITY_EXPIRES_IN_SECS")
-        .ok()
+    let secret = get_required_secret_text_or_file("IDENTITY_SECRET");
+    let expires_in_seconds = get_secret_text_or_file("IDENTITY_EXPIRES_IN_SECS")
         .and_then(|p| p.parse::<i64>().ok())
         .unwrap_or(3600);
     let expires_in = Duration::seconds(expires_in_seconds);
 
-    let refresh_secret = get_secret_text_or_file("REFRESH_SECRET");
-    let refresh_expires_in_days = env::var("REFRESH_EXPIRES_IN_DAYS")
-        .ok()
+    let refresh_secret = get_required_secret_text_or_file("REFRESH_SECRET");
+    let refresh_expires_in_days = get_secret_text_or_file("REFRESH_EXPIRES_IN_DAYS")
         .and_then(|p| p.parse::<i64>().ok())
         .unwrap_or(7);
     let refresh_expires_in = Duration::days(refresh_expires_in_days);
@@ -60,7 +70,7 @@ pub fn get_identity_config() -> auth::identity::IdentityConfig {
 pub fn get_cors_config() -> Cors {
     let cors = Cors::permissive().supports_credentials();
 
-    if let Ok(origins) = env::var("ALLOWED_ORIGINS") {
+    if let Some(origins) = get_secret_text_or_file("ALLOWED_ORIGINS") {
         origins
             .split(",")
             .fold(cors, |cors, origin| cors.allowed_origin(origin))
@@ -75,7 +85,20 @@ pub struct OAuthClientSecrets {
 }
 
 pub fn get_oauth_client_secrets() -> OAuthClientSecrets {
-    let id = get_secret_text_or_file("OAUTH_CLIENT_ID");
-    let secret = get_secret_text_or_file("OAUTH_CLIENT_SECRET");
-    OAuthClientSecrets { id, secret }
+    OAuthClientSecrets {
+        id: get_required_secret_text_or_file("OAUTH_CLIENT_ID"),
+        secret: get_required_secret_text_or_file("OAUTH_CLIENT_SECRET"),
+    }
+}
+
+pub struct SteamConfig {
+    pub app_id: String,
+    pub web_api_key: String,
+}
+
+pub fn get_steam_config() -> SteamConfig {
+    SteamConfig {
+        app_id: get_required_secret_text_or_file("STEAM_APP_ID"),
+        web_api_key: get_required_secret_text_or_file("STEAM_WEB_API_KEY"),
+    }
 }

+ 5 - 0
compose.yaml

@@ -20,6 +20,7 @@ services:
       - identity-refresh-secret
       - server-oauth-client-id
       - server-oauth-client-secret
+      - steam-web-api-key
     environment:
       POSTGRES_USER: postgres
       POSTGRES_PASSWORD_FILE: /run/secrets/db-password
@@ -33,6 +34,8 @@ services:
       ALLOWED_ORIGINS: "http://localhost:8060,https://multiplayer-test.bryanmylee.com"
       OAUTH_CLIENT_ID_FILE: /run/secrets/server-oauth-client-id
       OAUTH_CLIENT_SECRET_FILE: /run/secrets/server-oauth-client-secret
+      STEAM_APP_ID: 2843770
+      STEAM_WEB_API_KEY_FILE: /run/secrets/steam-web-api-key
     ports:
       - 18000:8000
     depends_on:
@@ -73,3 +76,5 @@ secrets:
     file: secrets/server-oauth-client-id.txt
   server-oauth-client-secret:
     file: secrets/server-oauth-client-secret.txt
+  steam-web-api-key:
+    file: secrets/steam-web-api-key.txt