neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
JSON API and device pairing for an Android app
02765fe · Gabriel Morell · 2026-10-28 22:29
Message
{commit_body(@commit)}
Files changed
modified
Cargo.lock
+8
−0
| 915 | 915 | "minijinja", |
| 916 | 916 | "minijinja-embed", |
| 917 | 917 | "opening-hours", |
| 918 | + "qrcode", | |
| 918 | 919 | "rand 0.9.5", |
| 919 | 920 | "reqwest", |
| 920 | 921 | "sea-orm", |
| 921 | 922 | "serde", |
| 922 | 923 | "serde_json", |
| 924 | + "sha2", | |
| 923 | 925 | "thiserror", |
| 924 | 926 | "tokio", |
| 925 | 927 | "tokio-stream", |
| 2277 | 2279 | "cc", |
| 2278 | 2280 | ] |
| 2279 | 2281 | |
| 2282 | +[[package]] | |
| 2283 | +name = "qrcode" | |
| 2284 | +version = "0.14.1" | |
| 2285 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2286 | +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" | |
| 2287 | + | |
| 2280 | 2288 | [[package]] |
| 2281 | 2289 | name = "quanta" |
| 2282 | 2290 | version = "0.12.6" |
modified
Cargo.toml
+4
−0
| 54 | 54 | |
| 55 | 55 | # Auth |
| 56 | 56 | argon2 = { version = "0.5", features = ["std"] } |
| 57 | +# API tokens are stored hashed | |
| 58 | +sha2 = "0.10" | |
| 59 | +# Pairing QR codes for the Android app, rendered server-side as SVG | |
| 60 | +qrcode = { version = "0.14", default-features = false, features = ["svg"] } | |
| 57 | 61 | |
| 58 | 62 | # CLI (registration-token) |
| 59 | 63 | clap = { version = "4.5", features = ["derive"] } |
modified
migration/src/lib.rs
+2
−0
| 6 | 6 | mod m20261028_000003_obsessed; |
| 7 | 7 | mod m20261028_000004_opening_hours; |
| 8 | 8 | mod m20261028_000005_collaboration; |
| 9 | +mod m20261029_000006_api_tokens; | |
| 9 | 10 | |
| 10 | 11 | pub struct Migrator; |
| 11 | 12 |
| 19 | 20 | Box::new(m20261028_000003_obsessed::Migration), |
| 20 | 21 | Box::new(m20261028_000004_opening_hours::Migration), |
| 21 | 22 | Box::new(m20261028_000005_collaboration::Migration), |
| 23 | + Box::new(m20261029_000006_api_tokens::Migration), | |
| 22 | 24 | ] |
| 23 | 25 | } |
| 24 | 26 | } |
added
migration/src/m20261029_000006_api_tokens.rs
+72
−0
| 1 | +//! Long-lived tokens for the Android app (and scripts), sent as | |
| 2 | +//! `Authorization: Bearer <token>`. Minted from the Devices page (shown | |
| 3 | +//! once, as a QR the app scans) or by signing in from the app with a | |
| 4 | +//! username and password. Only a SHA-256 of each token is stored. | |
| 5 | +use sea_orm_migration::prelude::*; | |
| 6 | + | |
| 7 | +#[derive(DeriveMigrationName)] | |
| 8 | +pub struct Migration; | |
| 9 | + | |
| 10 | +#[async_trait::async_trait] | |
| 11 | +impl MigrationTrait for Migration { | |
| 12 | + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 13 | + manager | |
| 14 | + .create_table( | |
| 15 | + Table::create() | |
| 16 | + .table(ApiTokens::Table) | |
| 17 | + .if_not_exists() | |
| 18 | + .col(ColumnDef::new(ApiTokens::Id).uuid().not_null().primary_key()) | |
| 19 | + .col(ColumnDef::new(ApiTokens::UserId).uuid().not_null()) | |
| 20 | + // hex SHA-256 of the token | |
| 21 | + .col(ColumnDef::new(ApiTokens::TokenHash).string_len(64).unique_key().not_null()) | |
| 22 | + // the first few characters, to tell tokens apart in a list | |
| 23 | + .col(ColumnDef::new(ApiTokens::Prefix).string_len(16).not_null()) | |
| 24 | + .col(ColumnDef::new(ApiTokens::Label).string_len(100).null()) | |
| 25 | + .col(ColumnDef::new(ApiTokens::LastUsedAt).timestamp_with_time_zone().null()) | |
| 26 | + .col(ColumnDef::new(ApiTokens::ExpiresAt).timestamp_with_time_zone().null()) | |
| 27 | + .col( | |
| 28 | + ColumnDef::new(ApiTokens::CreatedAt) | |
| 29 | + .timestamp_with_time_zone() | |
| 30 | + .not_null() | |
| 31 | + .default(Expr::current_timestamp()), | |
| 32 | + ) | |
| 33 | + .foreign_key( | |
| 34 | + ForeignKey::create() | |
| 35 | + .name("fk_api_tokens_user_id") | |
| 36 | + .from(ApiTokens::Table, ApiTokens::UserId) | |
| 37 | + .to(Users::Table, Users::Id) | |
| 38 | + .on_delete(ForeignKeyAction::Cascade), | |
| 39 | + ) | |
| 40 | + .to_owned(), | |
| 41 | + ) | |
| 42 | + .await?; | |
| 43 | + manager | |
| 44 | + .create_index( | |
| 45 | + Index::create().name("idx_api_tokens_user_id").table(ApiTokens::Table).col(ApiTokens::UserId).to_owned(), | |
| 46 | + ) | |
| 47 | + .await | |
| 48 | + } | |
| 49 | + | |
| 50 | + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 51 | + manager.drop_table(Table::drop().table(ApiTokens::Table).to_owned()).await | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +#[derive(DeriveIden)] | |
| 56 | +enum Users { | |
| 57 | + Table, | |
| 58 | + Id, | |
| 59 | +} | |
| 60 | + | |
| 61 | +#[derive(DeriveIden)] | |
| 62 | +enum ApiTokens { | |
| 63 | + Table, | |
| 64 | + Id, | |
| 65 | + UserId, | |
| 66 | + TokenHash, | |
| 67 | + Prefix, | |
| 68 | + Label, | |
| 69 | + LastUsedAt, | |
| 70 | + ExpiresAt, | |
| 71 | + CreatedAt, | |
| 72 | +} |
added
src/auth/api_tokens.rs
+123
−0
| 1 | +//! API tokens for the Android app and scripts: `Authorization: Bearer ff_…`. | |
| 2 | +//! | |
| 3 | +//! Tokens are random (`ff_` + 64 hex characters, from two v4 UUIDs) and only | |
| 4 | +//! their SHA-256 is stored, so the database can't hand out working tokens. | |
| 5 | +//! The full token is shown exactly once, when it's minted. | |
| 6 | +use anyhow::{Context, Result}; | |
| 7 | +use chrono::Utc; | |
| 8 | +use sea_orm::*; | |
| 9 | +use sha2::{Digest, Sha256}; | |
| 10 | +use uuid::Uuid; | |
| 11 | + | |
| 12 | +use crate::entities::{api_tokens, users}; | |
| 13 | + | |
| 14 | +const PREFIX: &str = "ff_"; | |
| 15 | + | |
| 16 | +pub fn generate() -> String { | |
| 17 | + format!("{PREFIX}{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) | |
| 18 | +} | |
| 19 | + | |
| 20 | +pub fn hash(token: &str) -> String { | |
| 21 | + Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect() | |
| 22 | +} | |
| 23 | + | |
| 24 | +/// Mint a token for `user_id`. Returns the stored row and the token itself, | |
| 25 | +/// which exists nowhere else afterwards. | |
| 26 | +pub async fn create( | |
| 27 | + db: &DatabaseConnection, | |
| 28 | + user_id: Uuid, | |
| 29 | + label: Option<String>, | |
| 30 | + expires_in_days: Option<i64>, | |
| 31 | +) -> Result<(api_tokens::Model, String)> { | |
| 32 | + let token = generate(); | |
| 33 | + let row = api_tokens::ActiveModel { | |
| 34 | + id: Set(Uuid::now_v7()), | |
| 35 | + user_id: Set(user_id), | |
| 36 | + token_hash: Set(hash(&token)), | |
| 37 | + prefix: Set(token[..PREFIX.len() + 6].to_string()), | |
| 38 | + label: Set(label), | |
| 39 | + last_used_at: Set(None), | |
| 40 | + expires_at: Set(expires_in_days.map(|d| (Utc::now() + chrono::Duration::days(d)).into())), | |
| 41 | + created_at: Set(Utc::now().into()), | |
| 42 | + } | |
| 43 | + .insert(db) | |
| 44 | + .await | |
| 45 | + .context("insert api token")?; | |
| 46 | + Ok((row, token)) | |
| 47 | +} | |
| 48 | + | |
| 49 | +/// The active user a token belongs to, if it's known and unexpired. Bumps | |
| 50 | +/// `last_used_at`, best effort. | |
| 51 | +pub async fn validate(db: &DatabaseConnection, token: &str) -> Result<Option<users::Model>> { | |
| 52 | + if !token.starts_with(PREFIX) { | |
| 53 | + return Ok(None); | |
| 54 | + } | |
| 55 | + let Some(row) = api_tokens::Entity::find() | |
| 56 | + .filter(api_tokens::Column::TokenHash.eq(hash(token))) | |
| 57 | + .one(db) | |
| 58 | + .await | |
| 59 | + .context("query api token")? | |
| 60 | + else { | |
| 61 | + return Ok(None); | |
| 62 | + }; | |
| 63 | + if row.expires_at.is_some_and(|e| e < Utc::now()) { | |
| 64 | + return Ok(None); | |
| 65 | + } | |
| 66 | + let user_id = row.user_id; | |
| 67 | + let mut seen: api_tokens::ActiveModel = row.into(); | |
| 68 | + seen.last_used_at = Set(Some(Utc::now().into())); | |
| 69 | + let _ = seen.update(db).await; | |
| 70 | + users::Entity::find_by_id(user_id) | |
| 71 | + .filter(users::Column::IsActive.eq(true)) | |
| 72 | + .one(db) | |
| 73 | + .await | |
| 74 | + .context("query token user") | |
| 75 | +} | |
| 76 | + | |
| 77 | +pub async fn list(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<api_tokens::Model>> { | |
| 78 | + api_tokens::Entity::find() | |
| 79 | + .filter(api_tokens::Column::UserId.eq(user_id)) | |
| 80 | + .order_by_desc(api_tokens::Column::CreatedAt) | |
| 81 | + .all(db) | |
| 82 | + .await | |
| 83 | + .context("list api tokens") | |
| 84 | +} | |
| 85 | + | |
| 86 | +/// Revoke one of `user_id`'s tokens. Returns whether it existed. | |
| 87 | +pub async fn revoke(db: &DatabaseConnection, user_id: Uuid, id: Uuid) -> Result<bool> { | |
| 88 | + Ok(api_tokens::Entity::delete_many() | |
| 89 | + .filter(api_tokens::Column::Id.eq(id)) | |
| 90 | + .filter(api_tokens::Column::UserId.eq(user_id)) | |
| 91 | + .exec(db) | |
| 92 | + .await | |
| 93 | + .context("revoke api token")? | |
| 94 | + .rows_affected | |
| 95 | + > 0) | |
| 96 | +} | |
| 97 | + | |
| 98 | +/// Revoke the token presented on this request (the app's "sign out"). | |
| 99 | +pub async fn revoke_token(db: &DatabaseConnection, token: &str) -> Result<()> { | |
| 100 | + api_tokens::Entity::delete_many() | |
| 101 | + .filter(api_tokens::Column::TokenHash.eq(hash(token))) | |
| 102 | + .exec(db) | |
| 103 | + .await | |
| 104 | + .context("revoke api token")?; | |
| 105 | + Ok(()) | |
| 106 | +} | |
| 107 | + | |
| 108 | +#[cfg(test)] | |
| 109 | +mod tests { | |
| 110 | + use super::*; | |
| 111 | + | |
| 112 | + #[test] | |
| 113 | + fn tokens_are_prefixed_long_and_distinct() { | |
| 114 | + let a = generate(); | |
| 115 | + assert!(a.starts_with("ff_") && a.len() == 3 + 64); | |
| 116 | + assert_ne!(a, generate()); | |
| 117 | + } | |
| 118 | + | |
| 119 | + #[test] | |
| 120 | + fn hash_is_stable_hex_sha256() { | |
| 121 | + assert_eq!(hash("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); | |
| 122 | + } | |
| 123 | +} |
modified
src/auth/mod.rs
+1
−0
Click to load diff…
modified
src/bin/registration-token.rs
+1
−1
Click to load diff…
modified
src/bin/webservice.rs
+23
−1
Click to load diff…
modified
src/config.rs
+8
−0
Click to load diff…
added
src/entities/api_tokens.rs
+25
−0
Click to load diff…
modified
src/entities/mod.rs
+1
−0
Click to load diff…
modified
src/entities/prelude.rs
+1
−0
Click to load diff…
modified
src/entities/users.rs
+2
−0
Click to load diff…
added
src/handlers/api.rs
+225
−0
Click to load diff…
modified
src/handlers/areas.rs
+58
−33
Click to load diff…
added
src/handlers/devices.rs
+142
−0
Click to load diff…
modified
src/handlers/lunch.rs
+22
−9
Click to load diff…
modified
src/handlers/mod.rs
+2
−0
Click to load diff…
modified
src/handlers/outings.rs
+166
−138
Click to load diff…
modified
src/handlers/restaurants.rs
+34
−29
Click to load diff…
modified
src/middleware/auth.rs
+43
−0
Click to load diff…
modified
src/middleware/mod.rs
+2
−1
Click to load diff…
added
templates/devices/created.html
+34
−0
Click to load diff…
added
templates/devices/list.html
+51
−0
Click to load diff…
modified
templates/root.html
+1
−0
Click to load diff…
modified
tests/templates.rs
+33
−0
Click to load diff…
Parents: 6a8dcc6