JSON API and device pairing for an Android app

02765fe · Gabriel Morell · 2026-10-28 22:29

37 files +1084 -212
Message
{commit_body(@commit)}

Files changed

modified Cargo.lock
+8 −0
@@ -915,11 +915,13 @@ dependencies = [
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,6 +2279,12 @@ dependencies = [
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,6 +54,10 @@ opening-hours = { version = "2.1.4", default-features = false }
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 +6,7 @@ mod m20261028_000002_create_outings;
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,6 +20,7 @@ impl MigratorTrait for Migrator {
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
@@ -0,0 +1,72 @@
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
@@ -0,0 +1,123 @@
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