steam login

This commit is contained in:
Robin Appelman 2023-04-09 15:33:31 +02:00
commit fc5cd1d24f
12 changed files with 926 additions and 70 deletions

645
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ tokio = { version = "1.27.0", features = ["full"] }
config = { version = "0.13.3", features = ["toml"] }
time = "0.3.20"
maud = { version = "0.24.0", git = "https://github.com/lambda-fairy/maud", rev = "7233cda35eed7bba91c9c55564d65498067c3822", features = ["axum"] }
axum = "0.6.12"
axum = { version = "0.6.12", features = ["headers"] }
hyper = "0.14.25"
hyperlocal = "0.8.0"
tower-http = { version = "0.4.0", features = ["trace"] }
@ -23,6 +23,11 @@ itertools = "0.10.5"
const-fnv1a-hash = "1.1.0"
const_base = "0.2.0"
const-str = "0.5.4"
steam-openid = "0.2.0"
async-session = "3.0.0"
quick-xml = { version = "0.28.1", features = ["serialize"] }
reqwest = "0.11.16"
rand = "0.8.5"
[build-dependencies]
lightningcss = { version = "1.0.0-alpha.40", features = ["browserslist", "visitor"] }

View file

@ -19,6 +19,8 @@
in rec {
# `nix develop`
devShell = pkgs.mkShell {
OPENSSL_NO_VENDOR = 1;
nativeBuildInputs = with pkgs; [
cargo
bacon
@ -27,6 +29,8 @@
clippy
cargo-audit
cargo-watch
pkg-config
openssl
];
};
});

View file

@ -10,6 +10,7 @@ use std::path::PathBuf;
pub struct Config {
pub listen: Listen,
pub database: DbConfig,
pub site: SiteConfig,
}
impl Config {
@ -46,3 +47,8 @@ pub enum Listen {
Socket { path: PathBuf },
Tcp { address: IpAddr, port: u16 },
}
#[derive(Debug, Deserialize)]
pub struct SiteConfig {
pub url: String,
}

View file

@ -2,3 +2,4 @@ pub mod chat;
pub mod demo;
pub mod player;
pub mod steam_id;
pub mod user;

View file

@ -1,14 +1,14 @@
use maud::Render;
use serde::{Serialize, Serializer};
use serde::{Deserialize, Serialize};
use sqlx::database::HasValueRef;
use sqlx::error::BoxDynError;
use sqlx::{Database, Decode, Type};
use std::borrow::Cow;
use std::fmt::Write;
use std::fmt::{Debug, Formatter};
use std::fmt::{Display, Write};
use steamid_ng::SteamID;
#[derive(Clone, PartialEq, Eq, Hash)]
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SteamId {
Id(u64),
Raw(Cow<'static, str>),
@ -51,25 +51,20 @@ impl SteamId {
let id = SteamID::from_steam3(s)?;
Ok(SteamId::Id(id.into()))
}
pub fn steamid64(&self) -> String {
match self {
SteamId::Id(id) => format!("{}", id),
SteamId::Raw(raw) => raw.to_string(),
}
}
}
impl Debug for SteamId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SteamId::Id(id) => SteamID::from(*id).fmt(f),
SteamId::Raw(raw) => raw.fmt(f),
}
}
}
impl Serialize for SteamId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
SteamId::Id(id) => serializer.collect_str(&SteamID::from(*id).steam3()),
SteamId::Raw(raw) => serializer.collect_str(raw),
SteamId::Id(id) => Debug::fmt(&SteamID::from(*id), f),
SteamId::Raw(raw) => Debug::fmt(raw, f),
}
}
}
@ -107,12 +102,18 @@ where
}
}
impl Display for SteamId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SteamId::Id(id) => write!(f, "{id}"),
SteamId::Raw(raw) => write!(f, "{raw}"),
}
}
}
impl Render for SteamId {
fn render_to(&self, buffer: &mut String) {
match self {
SteamId::Id(id) => write!(buffer, "{id}").unwrap(),
SteamId::Raw(raw) => buffer.push_str(raw),
}
write!(buffer, "{self}").unwrap()
}
}

80
src/data/user.rs Normal file
View file

@ -0,0 +1,80 @@
use crate::data::steam_id::SteamId;
use crate::Result;
use rand::distributions::Alphanumeric;
use rand::Rng;
use reqwest::get;
use serde::{Deserialize, Serialize};
use sqlx::{query, Executor, Postgres};
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub steam_id: SteamId,
pub name: String,
pub token: String,
}
impl User {
pub async fn get(
connection: impl Executor<'_, Database = Postgres> + Copy,
steam_id: SteamId,
) -> Result<Self> {
let user = query!(
r#"SELECT
token as "token!", name as "name!"
FROM users_named WHERE steamid = $1"#,
steam_id.steamid64()
)
.fetch_optional(connection)
.await?;
if let Some(user) = user {
Ok(User {
steam_id,
token: user.token,
name: user.name,
})
} else {
let profile = Self::fetch(&steam_id).await?;
let token: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(64)
.map(char::from)
.collect();
query!(
r#"INSERT INTO users(steamid, name, avatar, token)
VALUES($1, $2, $3, $4)"#,
steam_id.steamid64(),
profile.name,
profile.avatar,
token
)
.execute(connection)
.await?;
Ok(User {
steam_id,
token,
name: profile.name,
})
}
}
async fn fetch(steam_id: &SteamId) -> Result<Profile> {
let response = get(format!(
"https://steamcommunity.com/profiles/{steam_id}?xml=1"
))
.await?
.error_for_status()?
.text()
.await?;
Ok(quick_xml::de::from_str(&response)?)
}
}
#[derive(Debug, Deserialize)]
struct Profile {
#[serde(rename = "steamID")]
name: String,
#[serde(rename = "avatarMedium")]
avatar: String,
}

View file

@ -13,6 +13,14 @@ pub enum Error {
Io(#[from] std::io::Error),
#[error("page not found")]
NotFound,
#[error("Failed to validate steam auth")]
SteamAuth,
#[error(transparent)]
Request(#[from] reqwest::Error),
#[error(transparent)]
Xml(#[from] quick_xml::de::DeError),
#[error(transparent)]
Session(#[from] async_session::Error),
}
impl IntoResponse for Error {

View file

@ -3,19 +3,26 @@ mod config;
mod data;
mod error;
mod pages;
mod session;
pub use crate::config::Config;
use crate::config::Listen;
use crate::data::demo::{Demo, ListDemo};
use crate::data::steam_id::SteamId;
use crate::data::user::User;
use crate::pages::about::AboutPage;
use crate::pages::demo::DemoPage;
use crate::pages::index::Index;
use crate::pages::render;
use crate::session::{SessionData, COOKIE_NAME};
use asset::{serve_compiled, serve_static};
use axum::extract::{MatchedPath, Path};
use axum::http::Request;
use async_session::{MemoryStore, Session, SessionStore};
use axum::extract::{MatchedPath, Path, RawQuery};
use axum::headers::Cookie;
use axum::http::header::{LOCATION, SET_COOKIE};
use axum::http::{HeaderValue, Request, StatusCode};
use axum::response::IntoResponse;
use axum::{extract::State, routing::get, Router, Server};
use axum::{extract::State, routing::get, Router, Server, TypedHeader};
pub use error::Error;
use hyperlocal::UnixServerExt;
use maud::Markup;
@ -25,8 +32,9 @@ use std::fs::{remove_file, set_permissions, Permissions};
use std::net::SocketAddr;
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;
use steam_openid::SteamOpenId;
use tower_http::trace::TraceLayer;
use tracing::{info, info_span};
use tracing::{error, info, info_span};
use tracing_subscriber::{
fmt::layer, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer,
};
@ -35,6 +43,8 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
struct App {
connection: PgPool,
openid: SteamOpenId,
pub session_store: MemoryStore,
}
#[tokio::main]
@ -50,7 +60,14 @@ async fn main() -> Result<()> {
let config = Config::load(&config)?;
let connection = config.database.connect().await?;
let state = Arc::new(App { connection });
let session_store = MemoryStore::new();
let state = Arc::new(App {
connection,
openid: SteamOpenId::new(&config.site.url, "/login/callback")
.expect("invalid steam login url"),
session_store: session_store.clone(),
});
let app = Router::new()
.route("/", get(index))
@ -58,6 +75,9 @@ async fn main() -> Result<()> {
.route("/images/logo.png", get(serve_static!("../images/logo.png")))
.route("/images/logo.svg", get(serve_static!("../images/logo.svg")))
.route("/about", get(about))
.route("/login/callback", get(login_callback))
.route("/login", get(login))
.route("/logout", get(logout))
.route("/:id", get(demo))
.layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
@ -99,20 +119,100 @@ async fn main() -> Result<()> {
Ok(())
}
async fn index(State(app): State<Arc<App>>) -> Result<Markup> {
async fn index(State(app): State<Arc<App>>, session: SessionData) -> Result<Markup> {
let demos = ListDemo::list(&app.connection, None).await?;
Ok(render(Index { demos }))
Ok(render(Index { demos }, session))
}
async fn about(State(_app): State<Arc<App>>) -> Result<Markup> {
Ok(render(AboutPage { key: None }))
async fn about(State(_app): State<Arc<App>>, session: SessionData) -> Result<Markup> {
Ok(render(
AboutPage {
key: session.token(),
},
session,
))
}
async fn demo(State(app): State<Arc<App>>, Path(id): Path<u32>) -> Result<Markup> {
async fn demo(
State(app): State<Arc<App>>,
Path(id): Path<u32>,
session: SessionData,
) -> Result<Markup> {
let demo = Demo::by_id(&app.connection, id)
.await?
.ok_or(Error::NotFound)?;
Ok(render(DemoPage { demo }))
Ok(render(DemoPage { demo }, session))
}
async fn login_callback(
State(app): State<Arc<App>>,
RawQuery(query): RawQuery,
) -> Result<impl IntoResponse> {
let query = query.as_deref().unwrap_or_default();
let steam_id = app.openid.verify(query).await.map_err(|e| {
error!("{e:?}");
Error::SteamAuth
})?;
let steam_id = SteamId::new(steam_id);
let user = User::get(&app.connection, steam_id).await?;
let mut session = Session::new();
session
.insert("user", user)
.expect("failed to serialize user");
let cookie = app
.session_store
.store_session(session)
.await?
.unwrap_or_default();
Ok((
StatusCode::FOUND,
[
(
SET_COOKIE,
HeaderValue::from_str(&format!(
"{}={}; HttpOnly; SameSite=Lax; Path=/",
COOKIE_NAME, cookie
))
.expect("invalid cookie"),
),
(LOCATION, HeaderValue::from_static("/")),
],
))
}
async fn login(State(app): State<Arc<App>>) -> impl IntoResponse {
(
StatusCode::FOUND,
[(
LOCATION,
HeaderValue::from_str(app.openid.get_redirect_url()).unwrap(),
)],
)
}
async fn logout(
State(app): State<Arc<App>>,
cookie: Option<TypedHeader<Cookie>>,
) -> impl IntoResponse {
if let Some(session_cookie) = cookie.as_deref().and_then(|cookie| cookie.get(COOKIE_NAME)) {
if let Ok(Some(cookie)) = app.session_store.load_session(session_cookie.into()).await {
let _ = app.session_store.destroy_session(cookie);
}
}
(
StatusCode::FOUND,
[
(
SET_COOKIE,
HeaderValue::from_str(&format!(
"{}=; HttpOnly; SameSite=Lax; expires=Thu, 01 Jan 1970 00:00:00 GMT",
COOKIE_NAME
))
.expect("invalid cookie"),
),
(LOCATION, HeaderValue::from_str("/").unwrap()),
],
)
}
async fn handler_404() -> impl IntoResponse {

View file

@ -4,6 +4,7 @@ pub mod index;
mod plugin_section;
use crate::asset::saved_asset_url;
use crate::session::SessionData;
use maud::{html, Markup, DOCTYPE};
use std::borrow::Cow;
@ -12,7 +13,7 @@ pub trait Page {
fn render(&self) -> Markup;
}
pub fn render<T: Page>(page: T) -> Markup {
pub fn render<T: Page>(page: T, session: SessionData) -> Markup {
let style_url = saved_asset_url!("style.css");
html! {
(DOCTYPE)
@ -30,8 +31,14 @@ pub fn render<T: Page>(page: T) -> Markup {
span { a href = "/about" { "about" } }
span { a href = "/viewer" { "viewer" } }
span.beta { a href = "/editor" { "editor" } }
@if let SessionData::Authenticated(user) = session {
span.right { a href = "/logout" { "Logout" } }
span.right { a href = "/upload" { "Upload" } }
span.right { a href = (user.steam_id.profile_link()) { (user.name) } }
} @else {
span.right { a.steam-login href = "/login" { "Sign in through Steam" } }
}
}
.page { (page.render()) }
}
footer {

65
src/session.rs Normal file
View file

@ -0,0 +1,65 @@
use crate::data::user::User;
use crate::{App, Result};
use async_session::SessionStore as _;
use axum::extract::{FromRef, FromRequestParts};
use axum::http::request::Parts;
use axum::{async_trait, headers::Cookie, RequestPartsExt, TypedHeader};
use std::convert::Infallible;
use std::sync::Arc;
use tracing::debug;
pub const COOKIE_NAME: &str = "tf_session";
pub enum SessionData {
Authenticated(User),
UnAuthenticated,
}
impl SessionData {
pub fn token(&self) -> Option<String> {
match self {
SessionData::Authenticated(user) => Some(user.token.clone()),
SessionData::UnAuthenticated => None,
}
}
}
#[async_trait]
impl<S> FromRequestParts<S> for SessionData
where
Arc<App>: FromRef<S>,
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app: Arc<App> = Arc::from_ref(state);
let store = &app.session_store;
let cookie: Option<TypedHeader<Cookie>> = parts.extract().await.unwrap();
let session_cookie = cookie.as_ref().and_then(|cookie| cookie.get(COOKIE_NAME));
// return the new created session cookie for client
if session_cookie.is_none() {
return Ok(Self::UnAuthenticated);
}
debug!(
"SessionData: got session cookie from user agent, {}={}",
COOKIE_NAME,
session_cookie.unwrap()
);
// continue to decode the session cookie
let Ok(Some(session)) = store
.load_session(session_cookie.unwrap().to_owned())
.await else {
return Ok(Self::UnAuthenticated);
};
let Some(user) = session.get::<User>("user") else {
return Ok(Self::UnAuthenticated);
};
Ok(Self::Authenticated(user))
}
}

View file

@ -16,6 +16,7 @@ header {
padding: .5em 1em;
text-decoration: none;
display: block;
line-height: 24px;
}
.main a {
@ -68,6 +69,10 @@ header {
display: none;
}
}
& .right {
text-transform: none;
}
}
a.steam-login:before {
@ -84,8 +89,7 @@ a.steam-login:before {
top: 0;
}
a.steam-login {
text-transform: none;
a.steam-login, a.steam-login:visited {
display: inline-block;
height: 41px;
margin: -1px 0;