Hi
This commit is contained in:
parent
e7ad26ba33
commit
6bb60149ce
18 changed files with 3351 additions and 1 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
/target
|
||||
|
||||
sdk.db
|
||||
sdk_server.toml
|
2334
Cargo.lock
generated
Normal file
2334
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
30
Cargo.toml
Normal file
30
Cargo.toml
Normal file
|
@ -0,0 +1,30 @@
|
|||
[package]
|
||||
name = "hoyo-sdk"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.43.0", features = ["full"] }
|
||||
axum = "0.8.1"
|
||||
sqlx = { version = "0.8.3", features = ["sqlite", "runtime-tokio"] }
|
||||
|
||||
rsa = "0.9.7"
|
||||
password-hash = { version = "0.5.0", features = ["alloc", "rand_core"] }
|
||||
pbkdf2 = { version = "0.12.2", features = ["simple"] }
|
||||
rand = "0.8.5"
|
||||
|
||||
serde = { version = "1.0.217", features = ["derive"] }
|
||||
serde_json = "1.0.135"
|
||||
toml = "0.8.19"
|
||||
rbase64 = "2.0.3"
|
||||
thiserror = "2.0.11"
|
||||
|
||||
regex = "1.11.1"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
|
||||
[profile.release]
|
||||
strip = true # Automatically strip symbols from the binary.
|
||||
lto = true # Link-time optimization.
|
||||
opt-level = 3 # Optimize for speed.
|
||||
codegen-units = 1 # Maximum size reduction optimizations.
|
|
@ -1,3 +1,10 @@
|
|||
# hoyo-sdk
|
||||
|
||||
Hoyo SDK server emulator
|
||||
Portable and lightweight SDK server implementation in Rust.
|
||||
|
||||
### Usage
|
||||
Just compile and run. The server uses sqlite, so no additional database setup required.
|
||||
|
||||
### Register an account
|
||||
By default, you can register an account here: http://127.0.0.1:20100/account/register
|
||||
(assuming you're running server with the default configuration)
|
||||
|
|
191
html/register.html
Normal file
191
html/register.html
Normal file
|
@ -0,0 +1,191 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="pragma" content="no-cache" />
|
||||
<meta http-equiv="cache-control" content="no-cache" />
|
||||
<meta http-equiv="x-ua-compatible" content="IE=edge" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover"
|
||||
/>
|
||||
<title>Register account</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: hidden;
|
||||
overflow-x: auto;
|
||||
background-color: #fff;
|
||||
font-size: 100px;
|
||||
color: #333;
|
||||
font-family:
|
||||
Microsoft YaHei,
|
||||
Arial,
|
||||
sans-serif;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
color: #4ea4dc;
|
||||
}
|
||||
a:hover {
|
||||
color: #6cf;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: auto;
|
||||
background-color: #f8f8f8;
|
||||
position: relative;
|
||||
font-size: 0.14rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: #fff;
|
||||
border-radius: 0.04rem;
|
||||
overflow: hidden;
|
||||
width: 70%;
|
||||
margin: 0.3rem auto auto;
|
||||
}
|
||||
.header {
|
||||
font-size: 0.2rem;
|
||||
line-height: 0.6rem;
|
||||
text-align: center;
|
||||
background-color: #4ea4dc;
|
||||
color: #fff;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content .flash {
|
||||
margin: 0.2rem;
|
||||
padding: 0.1rem;
|
||||
width: 55%;
|
||||
text-align: center;
|
||||
font-size: 0.15rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.content .flash.error {
|
||||
background: #e7746b;
|
||||
color: #ffffff;
|
||||
}
|
||||
.content .flash.success {
|
||||
background: #8bc34a;
|
||||
color: #ffffff;
|
||||
}
|
||||
.content .flash.warning {
|
||||
background: #ff9800;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.content form {
|
||||
padding: 0.2rem;
|
||||
width: 55%;
|
||||
}
|
||||
.content form input {
|
||||
padding: 0 20px;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e6e6e6;
|
||||
color: #333;
|
||||
outline: none;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.content form input:focus {
|
||||
background-color: #f8f8f8;
|
||||
outline: none;
|
||||
}
|
||||
.content form button {
|
||||
padding: 0 20px;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: #4ea4dc;
|
||||
border-radius: 0.04rem;
|
||||
border: 1px solid #4ea4dc;
|
||||
color: #fff;
|
||||
font-size: 0.2rem;
|
||||
}
|
||||
.content form button:hover {
|
||||
background-color: #6cf;
|
||||
border-color: #6cf;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content .crumbs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 50%;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1400px) {
|
||||
.content .flash {
|
||||
width: 75%;
|
||||
}
|
||||
.content form {
|
||||
width: 75%;
|
||||
}
|
||||
.content .crumbs {
|
||||
width: 65%;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
.container {
|
||||
width: 90%;
|
||||
}
|
||||
.content .flash {
|
||||
width: 95%;
|
||||
}
|
||||
.content form {
|
||||
width: 100%;
|
||||
}
|
||||
.content .crumbs {
|
||||
width: 85%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div class="container">
|
||||
<div class="header">Register account</div>
|
||||
<div class="content">
|
||||
<form method="post">
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
required=""
|
||||
placeholder="Username"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required=""
|
||||
placeholder="Password"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password_v2"
|
||||
required=""
|
||||
placeholder="Confirm Password"
|
||||
/>
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
171
html/result.html
Normal file
171
html/result.html
Normal file
|
@ -0,0 +1,171 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="pragma" content="no-cache" />
|
||||
<meta http-equiv="cache-control" content="no-cache" />
|
||||
<meta http-equiv="x-ua-compatible" content="IE=edge" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover"
|
||||
/>
|
||||
<title>Register account</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: hidden;
|
||||
overflow-x: auto;
|
||||
background-color: #fff;
|
||||
font-size: 100px;
|
||||
color: #333;
|
||||
font-family:
|
||||
Microsoft YaHei,
|
||||
Arial,
|
||||
sans-serif;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
color: #4ea4dc;
|
||||
}
|
||||
a:hover {
|
||||
color: #6cf;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: auto;
|
||||
background-color: #f8f8f8;
|
||||
position: relative;
|
||||
font-size: 0.14rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: #fff;
|
||||
border-radius: 0.04rem;
|
||||
overflow: hidden;
|
||||
width: 70%;
|
||||
margin: 0.3rem auto auto;
|
||||
}
|
||||
.header {
|
||||
font-size: 0.2rem;
|
||||
line-height: 0.6rem;
|
||||
text-align: center;
|
||||
background-color: #4ea4dc;
|
||||
color: #fff;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content .flash {
|
||||
margin: 0.2rem;
|
||||
padding: 0.1rem;
|
||||
width: 55%;
|
||||
text-align: center;
|
||||
font-size: 0.15rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.content .flash.error {
|
||||
background: #e7746b;
|
||||
color: #ffffff;
|
||||
}
|
||||
.content .flash.success {
|
||||
background: #8bc34a;
|
||||
color: #ffffff;
|
||||
}
|
||||
.content .flash.warning {
|
||||
background: #ff9800;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.content form {
|
||||
padding: 0.2rem;
|
||||
width: 55%;
|
||||
}
|
||||
.content form input {
|
||||
padding: 0 20px;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e6e6e6;
|
||||
color: #333;
|
||||
outline: none;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.content form input:focus {
|
||||
background-color: #f8f8f8;
|
||||
outline: none;
|
||||
}
|
||||
.content form button {
|
||||
padding: 0 20px;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: #4ea4dc;
|
||||
border-radius: 0.04rem;
|
||||
border: 1px solid #4ea4dc;
|
||||
color: #fff;
|
||||
font-size: 0.2rem;
|
||||
}
|
||||
.content form button:hover {
|
||||
background-color: #6cf;
|
||||
border-color: #6cf;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content .crumbs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 50%;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1400px) {
|
||||
.content .flash {
|
||||
width: 75%;
|
||||
}
|
||||
.content form {
|
||||
width: 75%;
|
||||
}
|
||||
.content .crumbs {
|
||||
width: 65%;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
.container {
|
||||
width: 90%;
|
||||
}
|
||||
.content .flash {
|
||||
width: 95%;
|
||||
}
|
||||
.content form {
|
||||
width: 100%;
|
||||
}
|
||||
.content .crumbs {
|
||||
width: 85%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div class="container">
|
||||
<div class="header">Register account</div>
|
||||
<div class="content">
|
||||
<div class="flash %RESULT%">%MESSAGE%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
BIN
rsa/private_key.der
Normal file
BIN
rsa/private_key.der
Normal file
Binary file not shown.
2
sdk.default.toml
Normal file
2
sdk.default.toml
Normal file
|
@ -0,0 +1,2 @@
|
|||
http_addr = "127.0.0.1:20100"
|
||||
db_file = "sdk.db"
|
19
src/config.rs
Normal file
19
src/config.rs
Normal file
|
@ -0,0 +1,19 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
const DEFAULT: &str = include_str!("../sdk.default.toml");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SdkConfig {
|
||||
pub http_addr: String,
|
||||
pub db_file: String,
|
||||
}
|
||||
|
||||
pub fn load_or_create(path: &str) -> SdkConfig {
|
||||
std::fs::read_to_string(path).map_or_else(
|
||||
|_| {
|
||||
std::fs::write(path, DEFAULT).unwrap();
|
||||
toml::from_str(DEFAULT).unwrap()
|
||||
},
|
||||
|data| toml::from_str(&data).unwrap(),
|
||||
)
|
||||
}
|
83
src/database/mod.rs
Normal file
83
src/database/mod.rs
Normal file
|
@ -0,0 +1,83 @@
|
|||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use schema::{DbSdkAccountRow, Password, Username};
|
||||
use sqlx::{migrate::MigrateDatabase, Sqlite, SqlitePool};
|
||||
|
||||
pub mod schema;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DbContext(SqlitePool);
|
||||
type Result<T> = std::result::Result<T, sqlx::Error>;
|
||||
|
||||
impl DbContext {
|
||||
pub async fn connect(db_file: &str) -> Result<Self> {
|
||||
let db_url = format!("sqlite://{db_file}");
|
||||
|
||||
if !Sqlite::database_exists(&db_url).await.unwrap_or(false) {
|
||||
Sqlite::create_database(&db_url).await?;
|
||||
}
|
||||
|
||||
let db = Self(SqlitePool::connect(&db_url).await?);
|
||||
db.prepare_tables().await?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
async fn prepare_tables(&self) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS t_sdk_account (
|
||||
uid INTEGER PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.0)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_account(
|
||||
&self,
|
||||
username: Username,
|
||||
password: Password,
|
||||
) -> Result<Option<DbSdkAccountRow>> {
|
||||
if self
|
||||
.get_account_by_name(username.as_str().to_string())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let token = Alphanumeric.sample_string(&mut rand::thread_rng(), 64);
|
||||
Ok(Some(sqlx::query_as(
|
||||
"INSERT INTO t_sdk_account (token, username, password) VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(token)
|
||||
.bind(username.as_str())
|
||||
.bind(password.as_hash_str())
|
||||
.fetch_one(&self.0)
|
||||
.await?))
|
||||
}
|
||||
|
||||
pub async fn get_account_by_name(&self, username: String) -> Result<Option<DbSdkAccountRow>> {
|
||||
const SELECT_BY_NAME_QUERY: &str = r#"SELECT * FROM t_sdk_account WHERE username = ($1)"#;
|
||||
|
||||
sqlx::query_as(SELECT_BY_NAME_QUERY)
|
||||
.bind(&username)
|
||||
.fetch_optional(&self.0)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_account_by_uid(&self, uid: i32) -> Result<Option<DbSdkAccountRow>> {
|
||||
const SELECT_BY_UID_QUERY: &str = r#"SELECT * FROM t_sdk_account WHERE uid = ($1)"#;
|
||||
|
||||
sqlx::query_as(SELECT_BY_UID_QUERY)
|
||||
.bind(uid)
|
||||
.fetch_optional(&self.0)
|
||||
.await
|
||||
}
|
||||
}
|
90
src/database/schema.rs
Normal file
90
src/database/schema.rs
Normal file
|
@ -0,0 +1,90 @@
|
|||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
use sqlx::FromRow;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::util;
|
||||
|
||||
#[derive(sqlx::Encode, sqlx::Decode)]
|
||||
pub struct Username(String);
|
||||
|
||||
impl Username {
|
||||
pub fn parse(username: String) -> Option<Self> {
|
||||
static ALLOWED_USERNAME_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new("^[a-zA-Z0-9._@-]{6,25}$").unwrap());
|
||||
|
||||
ALLOWED_USERNAME_REGEX
|
||||
.is_match(&username)
|
||||
.then_some(Self(username))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl sqlx::Type<sqlx::Sqlite> for Username {
|
||||
fn type_info() -> <sqlx::Sqlite as sqlx::Database>::TypeInfo {
|
||||
<String as sqlx::Type<sqlx::Sqlite>>::type_info()
|
||||
}
|
||||
|
||||
fn compatible(ty: &<sqlx::Sqlite as sqlx::Database>::TypeInfo) -> bool {
|
||||
<String as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PasswordError {
|
||||
#[error("password pair mismatch")]
|
||||
PairMismatch,
|
||||
#[error("user input doesn't meet requirements")]
|
||||
RequirementsMismatch,
|
||||
#[error("failed to generate password hash: {0}")]
|
||||
HashFailed(pbkdf2::password_hash::Error),
|
||||
}
|
||||
|
||||
#[derive(sqlx::Encode, sqlx::Decode)]
|
||||
pub struct Password(String);
|
||||
|
||||
impl Password {
|
||||
pub fn new(password: String, password_v2: String) -> Result<Self, PasswordError> {
|
||||
(password == password_v2)
|
||||
.then_some(())
|
||||
.ok_or(PasswordError::PairMismatch)?;
|
||||
|
||||
matches!(password.len(), 8..30)
|
||||
.then_some(())
|
||||
.ok_or(PasswordError::RequirementsMismatch)?;
|
||||
|
||||
let hash = util::hash_string(&password).map_err(|err| PasswordError::HashFailed(err))?;
|
||||
Ok(Self(hash))
|
||||
}
|
||||
|
||||
pub fn verify(&self, password: &str) -> bool {
|
||||
util::verify_hash(password, &self.0).is_some()
|
||||
}
|
||||
|
||||
pub fn as_hash_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl sqlx::Type<sqlx::Sqlite> for Password {
|
||||
fn type_info() -> <sqlx::Sqlite as sqlx::Database>::TypeInfo {
|
||||
<String as sqlx::Type<sqlx::Sqlite>>::type_info()
|
||||
}
|
||||
|
||||
fn compatible(ty: &<sqlx::Sqlite as sqlx::Database>::TypeInfo) -> bool {
|
||||
<String as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
pub struct DbSdkAccountRow {
|
||||
pub uid: i32,
|
||||
pub token: String,
|
||||
pub username: Username,
|
||||
pub password: Password,
|
||||
}
|
60
src/handlers/combo_granter.rs
Normal file
60
src/handlers/combo_granter.rs
Normal file
|
@ -0,0 +1,60 @@
|
|||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RequestData {
|
||||
pub uid: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GranterTokenRequest {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppStateRef> {
|
||||
Router::new().route(
|
||||
"/{product_name}/combo/granter/login/v2/login",
|
||||
post(login_v2),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ResponseData {
|
||||
pub account_type: u32,
|
||||
pub combo_id: String,
|
||||
pub combo_token: String,
|
||||
pub data: &'static str,
|
||||
pub heartbeat: bool,
|
||||
pub open_id: String,
|
||||
}
|
||||
|
||||
async fn login_v2(
|
||||
state: State<AppStateRef>,
|
||||
request: Json<GranterTokenRequest>,
|
||||
) -> Json<Response<ResponseData>> {
|
||||
let Ok(data) = serde_json::from_str::<RequestData>(&request.data) else {
|
||||
return Json(Response::error(-101, "Account token error"));
|
||||
};
|
||||
|
||||
let Ok(uid) = data.uid.parse() else {
|
||||
return Json(Response::error(-101, "Account token error"));
|
||||
};
|
||||
|
||||
match state.db.get_account_by_uid(uid).await {
|
||||
Ok(Some(account)) if account.token == data.token => {
|
||||
success_rsp(data.uid.clone(), account.token)
|
||||
}
|
||||
_ => Json(Response::error(-101, "Account token error")),
|
||||
}
|
||||
}
|
||||
|
||||
fn success_rsp(uid: String, token: String) -> Json<Response<ResponseData>> {
|
||||
Json(Response::new(ResponseData {
|
||||
account_type: 1,
|
||||
combo_id: uid.clone(),
|
||||
combo_token: token,
|
||||
data: r#"{"guest":false}"#,
|
||||
heartbeat: false,
|
||||
open_id: uid,
|
||||
}))
|
||||
}
|
117
src/handlers/mdk_shield_api.rs
Normal file
117
src/handlers/mdk_shield_api.rs
Normal file
|
@ -0,0 +1,117 @@
|
|||
use super::*;
|
||||
|
||||
pub fn routes() -> Router<AppStateRef> {
|
||||
Router::new()
|
||||
.route("/{product_name}/mdk/shield/api/login", post(login))
|
||||
.route("/{product_name}/mdk/shield/api/verify", post(verify))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginRequest {
|
||||
pub account: String,
|
||||
pub password: String,
|
||||
pub is_crypto: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VerifyRequest {
|
||||
pub uid: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
struct ResponseData {
|
||||
pub account: ResponseAccountData,
|
||||
pub device_grant_required: bool,
|
||||
pub reactive_required: bool,
|
||||
pub realperson_required: bool,
|
||||
pub safe_mobile_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
struct ResponseAccountData {
|
||||
pub area_code: String,
|
||||
pub email: String,
|
||||
pub country: String,
|
||||
pub is_email_verify: String,
|
||||
pub token: String,
|
||||
pub uid: String,
|
||||
}
|
||||
|
||||
async fn login(
|
||||
state: State<AppStateRef>,
|
||||
request: Json<LoginRequest>,
|
||||
) -> Json<Response<ResponseData>> {
|
||||
if !request.is_crypto {
|
||||
return Json(Response::error(
|
||||
-10,
|
||||
"Invalid account format: unencrypted passwords are disabled by SDK security policy",
|
||||
));
|
||||
}
|
||||
|
||||
let Ok(password) = crate::util::rsa_decrypt(&request.password) else {
|
||||
return Json(Response::error(-10, "Your patch is outdated, get a new one at https://discord.gg/reversedrooms (Password decryption failed)"));
|
||||
};
|
||||
|
||||
let account = match state.db.get_account_by_name(request.account.clone()).await {
|
||||
Ok(Some(account)) => account,
|
||||
Ok(None) => return Json(Response::error(-101, "Account or password error")),
|
||||
Err(err) => {
|
||||
tracing::error!("database error: {err}");
|
||||
return Json(Response::error(-1, "Internal server error"));
|
||||
}
|
||||
};
|
||||
|
||||
if !account.password.verify(&password) {
|
||||
return Json(Response::error(-101, "Account or password error"));
|
||||
}
|
||||
|
||||
Json(Response::new(ResponseData {
|
||||
account: ResponseAccountData {
|
||||
area_code: String::from("**"),
|
||||
email: account.username.as_str().to_string(),
|
||||
country: String::from("RU"),
|
||||
is_email_verify: String::from("1"),
|
||||
uid: account.uid.to_string(),
|
||||
token: account.token,
|
||||
},
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
|
||||
async fn verify(
|
||||
state: State<AppStateRef>,
|
||||
request: Json<VerifyRequest>,
|
||||
) -> Json<Response<ResponseData>> {
|
||||
let Ok(uid) = request.uid.parse() else {
|
||||
return Json(Response::error(-101, "Account cache error"));
|
||||
};
|
||||
|
||||
let account = match state.db.get_account_by_uid(uid).await {
|
||||
Ok(Some(account)) => account,
|
||||
Ok(None) => return Json(Response::error(-101, "Account cache error")),
|
||||
Err(err) => {
|
||||
tracing::error!("database error: {err}");
|
||||
return Json(Response::error(-1, "Internal server error"));
|
||||
}
|
||||
};
|
||||
|
||||
if account.token == request.token {
|
||||
Json(Response::new(ResponseData {
|
||||
account: ResponseAccountData {
|
||||
area_code: String::from("**"),
|
||||
email: account.username.as_str().to_string(),
|
||||
country: String::from("RU"),
|
||||
is_email_verify: String::from("1"),
|
||||
uid: account.uid.to_string(),
|
||||
token: account.token,
|
||||
},
|
||||
..Default::default()
|
||||
}))
|
||||
} else {
|
||||
Json(Response::error(
|
||||
-101,
|
||||
"For account safety, please log in again",
|
||||
))
|
||||
}
|
||||
}
|
38
src/handlers/mod.rs
Normal file
38
src/handlers/mod.rs
Normal file
|
@ -0,0 +1,38 @@
|
|||
use crate::AppStateRef;
|
||||
use axum::{
|
||||
extract::State,
|
||||
response::Html,
|
||||
routing::{get, post},
|
||||
Form, Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod combo_granter;
|
||||
pub mod mdk_shield_api;
|
||||
pub mod register;
|
||||
pub mod risky_api;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Response<T> {
|
||||
data: Option<T>,
|
||||
message: String,
|
||||
retcode: i32,
|
||||
}
|
||||
|
||||
impl<T> Response<T> {
|
||||
pub fn new(data: T) -> Self {
|
||||
Self {
|
||||
data: Some(data),
|
||||
message: String::from("OK"),
|
||||
retcode: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(retcode: i32, message: &str) -> Self {
|
||||
Self {
|
||||
data: None,
|
||||
message: message.to_string(),
|
||||
retcode,
|
||||
}
|
||||
}
|
||||
}
|
82
src/handlers/register.rs
Normal file
82
src/handlers/register.rs
Normal file
|
@ -0,0 +1,82 @@
|
|||
use super::*;
|
||||
use crate::database::schema::{Password, PasswordError, Username};
|
||||
|
||||
const REGISTER_PAGE: Html<&str> = Html(include_str!("../../html/register.html"));
|
||||
|
||||
pub fn routes() -> Router<AppStateRef> {
|
||||
Router::new()
|
||||
.route("/account/register", get(|| async { REGISTER_PAGE }))
|
||||
.route("/account/register", post(process_register_request))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub password_v2: String,
|
||||
}
|
||||
|
||||
async fn process_register_request(
|
||||
State(state): State<AppStateRef>,
|
||||
Form(form): Form<RegisterForm>,
|
||||
) -> Html<String> {
|
||||
let Some(username) = Username::parse(form.username) else {
|
||||
return html_result(ResultStatus::Failure, "Invalid username format; should consists of characters [A-Za-z0-9_] and be at least 6 characters long.");
|
||||
};
|
||||
|
||||
let password = match Password::new(form.password, form.password_v2) {
|
||||
Ok(password) => password,
|
||||
Err(PasswordError::PairMismatch) => {
|
||||
return html_result(ResultStatus::Failure, "Passwords pair doesn't match")
|
||||
}
|
||||
Err(PasswordError::RequirementsMismatch) => {
|
||||
return html_result(
|
||||
ResultStatus::Failure,
|
||||
"Password should contain at least 8 and not more than 30 characters",
|
||||
)
|
||||
}
|
||||
Err(PasswordError::HashFailed(err)) => {
|
||||
tracing::error!("failed to hash password, err: {err}");
|
||||
return html_result(ResultStatus::Failure, "Internal server error");
|
||||
}
|
||||
};
|
||||
|
||||
match state.db.create_account(username, password).await {
|
||||
Ok(Some(_)) => html_result(
|
||||
ResultStatus::Success,
|
||||
"Account successfully registered, now you can use in-game login",
|
||||
),
|
||||
Ok(None) => html_result(
|
||||
ResultStatus::Failure,
|
||||
"Account with specified username already exists",
|
||||
),
|
||||
Err(err) => {
|
||||
tracing::error!("database operation error: {err}");
|
||||
html_result(ResultStatus::Failure, "Internal server error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ResultStatus {
|
||||
Failure,
|
||||
Success,
|
||||
}
|
||||
|
||||
impl ResultStatus {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Failure => "error",
|
||||
Self::Success => "success",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn html_result(result: ResultStatus, message: &str) -> Html<String> {
|
||||
static RESULT_HTML: &str = include_str!("../../html/result.html");
|
||||
|
||||
Html(
|
||||
RESULT_HTML
|
||||
.replace("%RESULT%", result.as_str())
|
||||
.replace("%MESSAGE%", message),
|
||||
)
|
||||
}
|
9
src/handlers/risky_api.rs
Normal file
9
src/handlers/risky_api.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
use super::*;
|
||||
|
||||
pub fn routes() -> Router<AppStateRef> {
|
||||
Router::new().route("/account/risky/api/check", post(risky_api_check))
|
||||
}
|
||||
|
||||
async fn risky_api_check(_: String) -> &'static str {
|
||||
r#"{"data":{},message:"OK",retcode:0}"#
|
||||
}
|
67
src/main.rs
Normal file
67
src/main.rs
Normal file
|
@ -0,0 +1,67 @@
|
|||
use std::{
|
||||
process::ExitCode,
|
||||
sync::{LazyLock, OnceLock},
|
||||
};
|
||||
|
||||
use axum::Router;
|
||||
use config::SdkConfig;
|
||||
use database::DbContext;
|
||||
use handlers::{combo_granter, mdk_shield_api, register, risky_api};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::error;
|
||||
|
||||
mod config;
|
||||
mod database;
|
||||
mod handlers;
|
||||
mod util;
|
||||
|
||||
struct AppState {
|
||||
db: DbContext,
|
||||
#[expect(dead_code)]
|
||||
config: &'static SdkConfig,
|
||||
}
|
||||
|
||||
type AppStateRef = &'static AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
static CONFIG: LazyLock<SdkConfig> =
|
||||
LazyLock::new(|| config::load_or_create("sdk_server.toml"));
|
||||
static STATE: OnceLock<AppState> = OnceLock::new();
|
||||
|
||||
println!("
|
||||
_____ _ _____ \n | __ \\ | | __ \\ \n | |__) |_____ _____ _ __ ___ ___ __| | |__) |___ ___ _ __ ___ ___ \n | _ // _ \\ \\ / / _ \\ '__/ __|/ _ \\/ _` | _ // _ \\ / _ \\| '_ ` _ \\/ __|\n | | \\ \\ __/\\ V / __/ | \\__ \\ __/ (_| | | \\ \\ (_) | (_) | | | | | \\__ \\\n |_| \\_\\___| \\_/ \\___|_| |___/\\___|\\__,_|_| \\_\\___/ \\___/|_| |_| |_|___/");
|
||||
|
||||
init_tracing();
|
||||
let db = match DbContext::connect(&CONFIG.db_file).await {
|
||||
Ok(db) => db,
|
||||
Err(err) => {
|
||||
error!("Failed to open SQLite database. Error: {err}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let _ = STATE.set(AppState {
|
||||
db,
|
||||
config: &CONFIG,
|
||||
});
|
||||
|
||||
let router = Router::new()
|
||||
.merge(risky_api::routes())
|
||||
.merge(register::routes())
|
||||
.merge(mdk_shield_api::routes())
|
||||
.merge(combo_granter::routes())
|
||||
.with_state(STATE.get().unwrap());
|
||||
|
||||
let listener = TcpListener::bind(&CONFIG.http_addr)
|
||||
.await
|
||||
.expect("TcpListener::bind failed. Is another instance of this server already running?");
|
||||
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::fmt().without_time().init();
|
||||
}
|
46
src/util.rs
Normal file
46
src/util.rs
Normal file
|
@ -0,0 +1,46 @@
|
|||
use rsa::{Pkcs1v15Encrypt, RsaPrivateKey};
|
||||
use thiserror::Error;
|
||||
|
||||
use password_hash::{PasswordHash, PasswordHasher, SaltString};
|
||||
use pbkdf2::Pbkdf2;
|
||||
const SDK_PRIVATE_KEY: &[u8] = include_bytes!("../rsa/private_key.der");
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CryptoError {
|
||||
#[error("failed to decrypt: {0}")]
|
||||
Decrypt(#[from] rsa::Error),
|
||||
#[error("failed to decode base64 string")]
|
||||
FromBase64,
|
||||
#[error("from_utf8 failed: {0}")]
|
||||
FromUtf8(#[from] std::string::FromUtf8Error),
|
||||
}
|
||||
|
||||
pub fn rsa_decrypt(cipher: &str) -> Result<String, CryptoError> {
|
||||
let private_key: RsaPrivateKey = rsa::pkcs8::DecodePrivateKey::from_pkcs8_der(SDK_PRIVATE_KEY)
|
||||
.expect("failed to decode private key");
|
||||
let payload = private_key.decrypt(
|
||||
Pkcs1v15Encrypt,
|
||||
&rbase64::decode(cipher).map_err(|_| CryptoError::FromBase64)?,
|
||||
)?;
|
||||
|
||||
Ok(String::from_utf8(payload)?)
|
||||
}
|
||||
|
||||
pub fn hash_string(content: &str) -> Result<String, pbkdf2::password_hash::Error> {
|
||||
const HASH_PARAMS: pbkdf2::Params = pbkdf2::Params {
|
||||
rounds: 10_000,
|
||||
output_length: 32,
|
||||
};
|
||||
|
||||
let salt = SaltString::generate(rand::thread_rng());
|
||||
let hash =
|
||||
Pbkdf2.hash_password_customized(content.as_bytes(), None, None, HASH_PARAMS, &salt)?;
|
||||
|
||||
Ok(hash.serialize().to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn verify_hash(content: &str, hash_str: &str) -> Option<()> {
|
||||
let hash = PasswordHash::new(hash_str).ok()?;
|
||||
hash.verify_password(&[&Pbkdf2], content).ok()
|
||||
}
|
Loading…
Reference in a new issue