use std::sync::Arc; use service_rpc::{ AccountRef, AuthApi, AuthRequest, AuthResponse, DeviceInfo, LoginOutcome, RpcResult, RpcService, Session, }; use crate::config::AuthConfig; use crate::store::{unavailable, AccountStore}; use crate::time::{days_between, format_utc, unix_millis, unix_seconds}; fn connect_failed(error: storage::StorageError) -> std::io::Error { std::io::Error::other(format!( "{error}\ncreate the database with `createdb scroll`, or point DATABASE_URL somewhere else" )) } pub const ERROR_CODE_GENERIC: i32 = 1; pub const ERROR_CODE_PATCH: i32 = 7; pub const ERROR_CODE_CLIENT_TOO_OLD: i32 = 8; pub const ERROR_CODE_BANNED: i32 = 11; pub struct AuthService { config: AuthConfig, store: AccountStore, } impl AuthService { pub async fn bootstrap(config: AuthConfig) -> std::io::Result> { let database = config.database.clone().unwrap_or_default(); let store = AccountStore::open(&database) .await .map_err(connect_failed)?; let accounts = store.count().await?; tracing::info!(accounts, url = %database.url, "account store ready"); Ok(Arc::new(Self { config, store })) } pub fn store(&self) -> &AccountStore { &self.store } fn version_verdict(&self, device: &DeviceInfo) -> Option<(i32, Option)> { if device.client_build < self.config.min_client_build { return Some(( ERROR_CODE_CLIENT_TOO_OLD, Some(format!( "client build {} is older than the required {}", device.client_build, self.config.min_client_build )), )); } if device.client_build > self.config.max_client_build { return Some(( ERROR_CODE_CLIENT_TOO_OLD, Some(format!( "client build {} is newer than the supported {}", device.client_build, self.config.max_client_build )), )); } None } } #[async_trait::async_trait] impl AuthApi for AuthService { async fn login( &self, account: AccountRef, pass_token: Option, device: DeviceInfo, ) -> RpcResult { if let Some((error_code, message)) = self.version_verdict(&device) { tracing::warn!(%account, build = device.client_build, "rejecting outdated client"); return Ok(LoginOutcome::Rejected { error_code, message, }); } let stored = if account.is_zero() { None } else { self.store.get(account).await.map_err(unavailable)? }; let stored = match stored { Some(existing) => { let presented = pass_token.as_deref().unwrap_or_default(); if existing.pass_token != presented { tracing::warn!(%account, "pass token mismatch"); return Ok(LoginOutcome::Rejected { error_code: ERROR_CODE_GENERIC, message: Some("invalid pass token".to_owned()), }); } if existing.banned { return Ok(LoginOutcome::Rejected { error_code: ERROR_CODE_BANNED, message: Some("account is banned".to_owned()), }); } existing } None if !account.is_zero() => { let adopted = self .store .adopt(account, pass_token.as_deref()) .await .map_err(unavailable)?; tracing::info!(account = %adopted.account_ref(), "adopted the account the client asked for"); adopted } None => { let created = self.store.create().await.map_err(unavailable)?; tracing::info!(account = %created.account_ref(), "created account"); created } }; let refreshed = self .store .touch_session(stored.account_ref()) .await .map_err(unavailable)? .unwrap_or(stored); let now = unix_seconds(); Ok(LoginOutcome::Accepted(Session { account: refreshed.account_ref(), pass_token: refreshed.pass_token.clone(), session_count: refreshed.session_count, play_time_seconds: refreshed.play_time_seconds, days_since_started_playing: days_between(refreshed.created_at, now), account_created_date: format_utc(refreshed.created_at), server_time: unix_millis().to_string(), })) } async fn resolve(&self, account: AccountRef) -> RpcResult { Ok(self .store .get(account) .await .map_err(unavailable)? .is_some()) } } #[async_trait::async_trait] impl RpcService for AuthService { type Request = AuthRequest; type Response = AuthResponse; fn service_name(&self) -> &'static str { "auth" } async fn call(&self, request: AuthRequest) -> RpcResult { match request { AuthRequest::Login { account, pass_token, device, } => Ok(AuthResponse::Login( self.login(account, pass_token, device).await?, )), AuthRequest::Resolve { account } => Ok(AuthResponse::Resolved { known: self.resolve(account).await?, }), } } }