refactor: improve code quality and add comprehensive documentation
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>
This commit is contained in:
parent
8d25353f04
commit
e710c6dc62
7 changed files with 131 additions and 29 deletions
122
README.md
122
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**.
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use once_cell::sync::Lazy;
|
|||
use rand::RngCore;
|
||||
|
||||
static ID_GENERATOR: Lazy<BlockIdGenerator<char>> =
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ impl<K: SortKey> ListRequest<K> {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn default() -> Self {
|
||||
pub fn default_query() -> Self {
|
||||
let key = K::default();
|
||||
Self::new(
|
||||
1,
|
||||
|
|
@ -164,7 +164,7 @@ impl<K: SortKey> ListRequest<K> {
|
|||
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<T> Page<T> {
|
|||
1
|
||||
} else {
|
||||
let size = self.page_size as u64;
|
||||
((self.total + size - 1) / size) as u32
|
||||
(self.total.div_ceil(size)) as u32
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
|||
pub fn generate_token() -> Result<String> {
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -442,11 +442,11 @@ 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") {
|
||||
if let SqlxError::Database(db_err) = &err
|
||||
&& db_err.code().as_deref() == Some("787")
|
||||
{
|
||||
return RepositoryError::conflict(message);
|
||||
}
|
||||
}
|
||||
|
||||
RepositoryError::unexpected(err.to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,17 +51,17 @@ 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() {
|
||||
if let sqlx::Error::Database(db_err) = &err
|
||||
&& db_err.is_unique_violation()
|
||||
{
|
||||
return RepositoryError::conflict("token already exists");
|
||||
}
|
||||
}
|
||||
RepositoryError::unexpected(err.to_string())
|
||||
})?;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue