From e710c6dc62f571f27b5473198db95f0b52d94328 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:03:45 +0000 Subject: [PATCH] refactor: improve code quality and add comprehensive documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code Quality Improvements: - Fix hex literal grouping in ID generator (0xB10C_1D -> 0x00B1_0C1D) - Rename ListQuery::default() to default_query() to avoid confusion with Default trait - Use div_ceil() instead of manual ceiling division - Remove unnecessary borrows in auth token generation and hashing - Simplify nested if statements in error handling Documentation: - Add comprehensive authentication section to README - Document environment variables for server and CLI - Add security best practices and considerations - Document password hashing (Argon2id), token storage (SHA-256), and session management - Include step-by-step authentication setup guide - Add production deployment recommendations All 70 tests pass (8 unit + 46 server + 16 CLI) ✅ Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com> --- README.md | 122 ++++++++++++++++++++-- src/cli/tokens.rs | 2 +- src/domain/ids.rs | 2 +- src/domain/listing.rs | 6 +- src/infrastructure/auth.rs | 6 +- src/infrastructure/repositories/roasts.rs | 8 +- src/infrastructure/repositories/tokens.rs | 14 +-- 7 files changed, 131 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 450f7b9..60a0ec1 100644 --- a/README.md +++ b/README.md @@ -18,26 +18,57 @@ that enables client-side reactivity with [Datastar](https://data-star.dev/). B{rew}log ships as one executable. You decide whether it acts as a server or a client. -Start the server: +### First-time setup + +On first start, you **must** set an admin password via the `BREWLOG_ADMIN_PASSWORD` environment variable: ```bash -brewlog serve +BREWLOG_ADMIN_PASSWORD="your-secure-password" brewlog serve ``` -Interact with a running instance via the CLI: +This creates the admin user in the database. On subsequent starts, the password is not required. + +### Authentication + +Brewlog supports two authentication methods: + +1. **Web Frontend**: Session-based authentication via login page +2. **CLI/API**: Token-based authentication via Bearer tokens + +#### Web Authentication + +1. Start the server and visit `http://localhost:3000` +2. Click "Login" in the navigation bar +3. Sign in with username `admin` and your password +4. You're now authenticated and can create/update/delete records + +#### CLI/API Authentication + +First, create an API token: ```bash -# Point the CLI at your server (defaults to http://127.0.0.1:3000) +brewlog create-token --name "my-cli-token" +# Enter username: admin +# Enter password: **** +# +# Token created successfully! +# Token ID: abc123 +# Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +Export the token and use it for all CLI commands: + +```bash +export BREWLOG_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." export BREWLOG_URL=http://localhost:3000 -# Add a roaster +# Now all write operations work brewlog add-roaster \ --name "Radical Roasters" \ --country "UK" \ --city "Bristol" \ --homepage "https://radicalroasters.co.uk" -# Add a roast metadata and tasting notes brewlog add-roast \ --roaster-id "deadbeef" \ --name "Chelbesa Lot 2" \ @@ -48,15 +79,27 @@ brewlog add-roast \ --tasting-notes "Blueberry, Jasmine" ``` -Every CLI command maps to an HTTP endpoint. You can perform the same operations with `curl`, -Postman, or any HTTP client: +#### Token Management + +```bash +# List your active tokens +brewlog list-tokens + +# Revoke a token +brewlog revoke-token --id abc123 +``` + +#### API Usage + +For direct API access, include your token as a Bearer token: ```bash curl http://localhost:3000/api/v1/roasters \ - --json '{"name":"Radical Roasters","country":"UK","city":"Bristol","homepage":"https://radicalroasters.co.uk"}' + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + --json '{"name":"Radical Roasters","country":"UK"}' ``` -Once the server is running, visit `http://localhost:3000` to access the user interface. +**Note**: All read operations (GET requests) are public and don't require authentication. Only write operations (POST/PUT/DELETE) require authentication. ## Installation @@ -75,3 +118,62 @@ During development you can run directly: ```bash cargo run -- serve ``` + +## Environment Variables + +### Server Configuration + +- **`BREWLOG_ADMIN_PASSWORD`** *(required on first start)*: Sets the admin user password. Must be provided when starting the server for the first time. +- **`BREWLOG_SECURE_COOKIES`**: Set to `"true"` to enable the `Secure` flag on session cookies (HTTPS-only transmission). Recommended for production deployments. +- **`DATABASE_URL`**: SQLite database file path (default: `brewlog.db`) +- **`BIND_ADDRESS`**: Server bind address (default: `0.0.0.0:3000`) + +### CLI Configuration + +- **`BREWLOG_URL`**: API server URL (default: `http://127.0.0.1:3000`) +- **`BREWLOG_TOKEN`**: API authentication token for write operations + +## Security Considerations + +### Password Security + +- Passwords are hashed using **Argon2id** with industry-standard secure defaults +- Password hashing uses constant-time comparison to prevent timing attacks +- Never store passwords in plain text - always use the password prompt + +### Token Security + +- API tokens are cryptographically secure 32-byte random values +- Tokens are stored as **SHA-256 hashes** in the database +- Token values are only displayed **once** at creation time +- Revoked tokens cannot be reused + +### Session Security + +- Session tokens are 256-bit cryptographically secure random values +- Sessions are stored in the database with 30-day expiration +- Session tokens are hashed with **SHA-256** before storage +- Cookies are **HttpOnly** and **SameSite=Strict** for CSRF protection +- Sessions are properly invalidated on logout + +### Production Deployment + +When deploying to production: + +1. **Enable HTTPS**: Set `BREWLOG_SECURE_COOKIES=true` to ensure cookies are only transmitted over HTTPS +2. **Use Strong Passwords**: Choose a strong admin password (12+ characters, mixed case, numbers, symbols) +3. **Restrict Access**: Use firewall rules to limit who can access the server +4. **Regular Updates**: Keep your Brewlog installation up to date +5. **Backup Database**: Regularly backup your `brewlog.db` file +6. **Revoke Unused Tokens**: Periodically review and revoke API tokens you no longer need + +### Authentication Model + +Brewlog uses a **single-user** authentication model: + +- Only one user account exists (`admin`) +- No sign-up or password recovery flows +- No multi-user support or permissions +- All authenticated users have full access to all operations + +This design assumes Brewlog is deployed for **personal use** or in a **trusted environment**. diff --git a/src/cli/tokens.rs b/src/cli/tokens.rs index ca01c51..4d41d9c 100644 --- a/src/cli/tokens.rs +++ b/src/cli/tokens.rs @@ -33,7 +33,7 @@ pub async fn create_token(client: &BrewlogClient, cmd: CreateTokenCommand) -> Re // Create the token let token_response = client .tokens() - .create(&username, &password, &cmd.name) + .create(username, &password, &cmd.name) .await?; println!("\nToken created successfully!"); diff --git a/src/domain/ids.rs b/src/domain/ids.rs index d823216..33e4b30 100644 --- a/src/domain/ids.rs +++ b/src/domain/ids.rs @@ -3,7 +3,7 @@ use once_cell::sync::Lazy; use rand::RngCore; static ID_GENERATOR: Lazy> = - Lazy::new(|| BlockIdGenerator::new(Alphabet::alphanumeric(), 0xB10C_1D_u128, 4)); + Lazy::new(|| BlockIdGenerator::new(Alphabet::alphanumeric(), 0x00B1_0C1D_u128, 4)); pub fn generate_id() -> String { let mut rng = rand::thread_rng(); diff --git a/src/domain/listing.rs b/src/domain/listing.rs index 7e9c1ac..d4f7255 100644 --- a/src/domain/listing.rs +++ b/src/domain/listing.rs @@ -97,7 +97,7 @@ impl ListRequest { } } - pub fn default() -> Self { + pub fn default_query() -> Self { let key = K::default(); Self::new( 1, @@ -164,7 +164,7 @@ impl ListRequest { return Self::new(1, self.page_size, self.sort_key, self.sort_direction); } - let last_page = ((total + limit as u64 - 1) / limit as u64) as u32; + let last_page = (total.div_ceil(limit as u64)) as u32; let adjusted_page = self.page.min(last_page.max(1)); Self::new( adjusted_page, @@ -200,7 +200,7 @@ impl Page { 1 } else { let size = self.page_size as u64; - ((self.total + size - 1) / size) as u32 + (self.total.div_ceil(size)) as u32 } } diff --git a/src/infrastructure/auth.rs b/src/infrastructure/auth.rs index 211f141..4a6e4b8 100644 --- a/src/infrastructure/auth.rs +++ b/src/infrastructure/auth.rs @@ -38,7 +38,7 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result { pub fn generate_token() -> Result { let mut token_bytes = [0u8; 32]; OsRng.fill_bytes(&mut token_bytes); - Ok(general_purpose::STANDARD.encode(&token_bytes)) + Ok(general_purpose::STANDARD.encode(token_bytes)) } /// Hashes a token for storage using SHA-256 @@ -46,14 +46,14 @@ pub fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); let result = hasher.finalize(); - general_purpose::STANDARD.encode(&result) + general_purpose::STANDARD.encode(result) } /// Generates a session token for cookie-based authentication pub fn generate_session_token() -> String { let mut token_bytes = [0u8; 32]; OsRng.fill_bytes(&mut token_bytes); - general_purpose::URL_SAFE_NO_PAD.encode(&token_bytes) + general_purpose::URL_SAFE_NO_PAD.encode(token_bytes) } #[cfg(test)] diff --git a/src/infrastructure/repositories/roasts.rs b/src/infrastructure/repositories/roasts.rs index d811d2a..c551f41 100644 --- a/src/infrastructure/repositories/roasts.rs +++ b/src/infrastructure/repositories/roasts.rs @@ -442,10 +442,10 @@ impl RoastRepository for SqlRoastRepository { } fn map_insert_error(err: SqlxError, message: &'static str) -> RepositoryError { - if let SqlxError::Database(db_err) = &err { - if db_err.code().as_deref() == Some("787") { - return RepositoryError::conflict(message); - } + if let SqlxError::Database(db_err) = &err + && db_err.code().as_deref() == Some("787") + { + return RepositoryError::conflict(message); } RepositoryError::unexpected(err.to_string()) diff --git a/src/infrastructure/repositories/tokens.rs b/src/infrastructure/repositories/tokens.rs index f50d43f..5fb20c8 100644 --- a/src/infrastructure/repositories/tokens.rs +++ b/src/infrastructure/repositories/tokens.rs @@ -51,16 +51,16 @@ impl TokenRepository for SqlTokenRepository { .bind(&token.user_id) .bind(&token.token_hash) .bind(&token.name) - .bind(&token.created_at) - .bind(&token.last_used_at) - .bind(&token.revoked_at) + .bind(token.created_at) + .bind(token.last_used_at) + .bind(token.revoked_at) .execute(&self.pool) .await .map_err(|err| { - if let sqlx::Error::Database(db_err) = &err { - if db_err.is_unique_violation() { - return RepositoryError::conflict("token already exists"); - } + if let sqlx::Error::Database(db_err) = &err + && db_err.is_unique_violation() + { + return RepositoryError::conflict("token already exists"); } RepositoryError::unexpected(err.to_string()) })?;