Compare commits

..
2 Commits
Author SHA1 Message Date
rabadjiyskiandCopilot a9be038a7d Record release co-author attribution
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-23 19:22:05 +03:00
rabadjiyski c4a368d9f7 Release v0.6.0
Add database-backed user management and password reset tooling.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-23 19:21:52 +03:00
18 changed files with 1484 additions and 154 deletions

No files matched your search

+21 -7
View File
@@ -10,6 +10,7 @@ Multi-connection SSH client that runs in your browser with JWT authentication.
- **SFTP File Manager**: Built-in file manager with upload/download capabilities - **SFTP File Manager**: Built-in file manager with upload/download capabilities
- **Terminal emulator**: Uses xterm.js for a full terminal experience - **Terminal emulator**: Uses xterm.js for a full terminal experience
- **Connection management**: Sidebar with connection list, status indicators, and quick actions - **Connection management**: Sidebar with connection list, status indicators, and quick actions
- **Version updates**: Displays the running version and checks for a newer stable release every 24 hours, caching the response in the browser
- **Remote load monitoring**: Active SSH connections display the remote Linux 1/5/15-minute load average - **Remote load monitoring**: Active SSH connections display the remote Linux 1/5/15-minute load average
- **Session Recording**: Automatic recording of SSH sessions in asciinema format with playback and configurable retention - **Session Recording**: Automatic recording of SSH sessions in asciinema format with playback and configurable retention
- **Dark theme**: Modern dark UI design - **Dark theme**: Modern dark UI design
@@ -27,22 +28,35 @@ docker-compose -f deployment/docker-compose.yml up -d --build
Open http://localhost:8080 in your browser. The application is served via a Caddy reverse proxy. Open http://localhost:8080 in your browser. The application is served via a Caddy reverse proxy.
### 3. Login ### 3. Create the Administrator
Default credentials: On first start, create the administrator account in the browser. Use a password with at least 12 characters.
- Username: `admin`
- Password: `admin`
## Configuration ## Configuration
### Users ### Users
Set users via environment variable in `deployment/docker-compose.yml`: On a fresh installation, open the application to create the first administrator.
For an existing installation, users configured with `WEBSSH_USERS` are imported into the database once.
The first imported user becomes an administrator, the remaining imported users have the `user` role, and
each imported user must change their password at their next login. Remove `WEBSSH_USERS` after migration:
future user accounts are stored in the database.
To import legacy users via the environment variable in `deployment/docker-compose.yml`:
```yaml ```yaml
environment: environment:
- WEBSSH_USERS=user1:pass1,user2:pass2 - WEBSSH_USERS=user1:pass1,user2:pass2
``` ```
New passwords must contain at least 12 characters. Passwords are stored as salted PBKDF2-HMAC-SHA256 hashes.
Administrators can create and delete accounts from **Manage Users**; new users must replace their temporary password at first login.
To reset an account password from a running Docker container, run the command interactively:
```bash
docker exec -it webssh-backend password-reset
```
The tool asks for the username (or accepts it as `password-reset username`), prompts for the new password without echoing it, invalidates existing sessions for that account, and requires the user to change the temporary password at their next login.
### JWT Secret ### JWT Secret
Set custom JWT secret: Set custom JWT secret:
@@ -203,7 +217,7 @@ Pre-built multi-arch images available:
```bash ```bash
docker pull danaildr/webssh:latest docker pull danaildr/webssh:latest
docker pull danaildr/webssh:0.5.1 docker pull danaildr/webssh:0.6.0
``` ```
Supported architectures: `linux/amd64`, `linux/arm64`, `linux/arm/v7` Supported architectures: `linux/amd64`, `linux/arm64`, `linux/arm/v7`
@@ -211,7 +225,7 @@ Supported architectures: `linux/amd64`, `linux/arm64`, `linux/arm/v7`
## Security Notes ## Security Notes
- Always use HTTPS in production (configure reverse proxy with SSL) - Always use HTTPS in production (configure reverse proxy with SSL)
- Change default credentials - Create a strong administrator password during first-run setup
- Set a strong JWT_SECRET - Set a strong JWT_SECRET
- The backend uses `AutoAddPolicy()` for SSH host keys (testing only - change for production) - The backend uses `AutoAddPolicy()` for SSH host keys (testing only - change for production)
- Token expiry: 8 hours - Token expiry: 8 hours
+2 -1
View File
@@ -71,12 +71,13 @@ WORKDIR /app/src
# Copy application and static files (owned by webssh in one layer, no separate chown pass) # Copy application and static files (owned by webssh in one layer, no separate chown pass)
COPY --chown=webssh:webssh src/ /app/src/ COPY --chown=webssh:webssh src/ /app/src/
COPY deployment/docker-entrypoint.sh /usr/local/bin/ COPY deployment/docker-entrypoint.sh /usr/local/bin/
COPY deployment/scripts/password-reset /usr/local/bin/password-reset
# Create .ssh directory for host keys # Create .ssh directory for host keys
RUN mkdir -p /app/src/.ssh && chown webssh:webssh /app/src/.ssh RUN mkdir -p /app/src/.ssh && chown webssh:webssh /app/src/.ssh
# Copy entrypoint # Copy entrypoint
RUN chmod +x /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/password-reset
# Copy the Caddy binary from the official image # Copy the Caddy binary from the official image
COPY --from=caddy /usr/bin/caddy /usr/local/bin/caddy COPY --from=caddy /usr/bin/caddy /usr/local/bin/caddy
+2 -2
View File
@@ -12,8 +12,8 @@ services:
- TZ=Europe/Sofia - TZ=Europe/Sofia
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- LOG_LEVEL=INFO - LOG_LEVEL=INFO
# Configure users: format is username:password,username2:password2 # For a one-time import from a legacy installation, configure user:password pairs:
- WEBSSH_USERS=admin:admin # - WEBSSH_USERS=user1:pass1,user2:pass2
# Max upload size, enforced by both Caddy and the backend (e.g. "30MB", "512KB") # Max upload size, enforced by both Caddy and the backend (e.g. "30MB", "512KB")
- MAX_UPLOAD_SIZE=30MB - MAX_UPLOAD_SIZE=30MB
# Session Recording Configuration # Session Recording Configuration
+1 -1
View File
@@ -7,7 +7,7 @@ set -e
# Configuration # Configuration
IMAGE_NAME="danaildr/webssh" IMAGE_NAME="danaildr/webssh"
VERSION="0.5.1" VERSION="0.6.0"
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v7" PLATFORMS="linux/amd64,linux/arm64,linux/arm/v7"
# Colors # Colors
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -eq 0 ]; then
exec gosu webssh python /app/src/password_reset.py "$@"
fi
exec python /app/src/password_reset.py "$@"
+10 -5
View File
@@ -26,7 +26,6 @@ Multi-connection SSH client that runs entirely in your browser with JWT authenti
```bash ```bash
docker run -d \ docker run -d \
-p 8080:8080 \ -p 8080:8080 \
-e WEBSSH_USERS=admin:admin \
-e MAX_UPLOAD_SIZE=100M \ -e MAX_UPLOAD_SIZE=100M \
--name webssh \ --name webssh \
--restart unless-stopped \ --restart unless-stopped \
@@ -34,14 +33,13 @@ docker run -d \
``` ```
Then open http://localhost:8080 in your browser. Then open http://localhost:8080 in your browser.
**Default credentials:** `admin` / `admin` On first start, create the administrator account in the browser. Use a password with at least 12 characters.
### With Persistent Data & Auto-Lock ### With Persistent Data & Auto-Lock
```bash ```bash
docker run -d \ docker run -d \
-p 8080:8080 \ -p 8080:8080 \
-v webssh_data:/data \ -v webssh_data:/data \
-e WEBSSH_USERS=user1:pass1,user2:pass2 \
-e JWT_SECRET=your-256-bit-secret-here \ -e JWT_SECRET=your-256-bit-secret-here \
-e MAX_UPLOAD_SIZE=500M \ -e MAX_UPLOAD_SIZE=500M \
-e RECORDING_ENABLED=true \ -e RECORDING_ENABLED=true \
@@ -51,13 +49,19 @@ docker run -d \
danaildr/webssh:latest danaildr/webssh:latest
``` ```
For a one-time import of legacy accounts, optionally set `WEBSSH_USERS=user1:pass1,user2:pass2`.
The first imported user becomes an administrator, other accounts have the `user` role, and all
imported users must change their password at first login.
Administrators can create and delete users from the application.
To reset an account password in a running container, use `docker exec -it webssh password-reset`.
**Note:** Auto-lock and auto-disconnect settings are configured per connection in the connection settings modal. **Note:** Auto-lock and auto-disconnect settings are configured per connection in the connection settings modal.
## Environment Variables ## Environment Variables
| Variable | Description | Default | Required | | Variable | Description | Default | Required |
|----------|-------------|---------|----------| |----------|-------------|---------|----------|
| `WEBSSH_USERS` | Comma-separated user:pass pairs | `admin:admin` | No | | `WEBSSH_USERS` | Optional one-time import of legacy user:pass pairs; imported accounts must change their passwords | None | No |
| `JWT_SECRET` | Secret for token signing | Auto-generated | Recommended | | `JWT_SECRET` | Secret for token signing | Auto-generated | Recommended |
| `MAX_UPLOAD_SIZE` | Max file upload (e.g., `10M`, `1G`) | `100M` | No | | `MAX_UPLOAD_SIZE` | Max file upload (e.g., `10M`, `1G`) | `100M` | No |
| `RECORDING_ENABLED` | Enable session recording | `true` | No | | `RECORDING_ENABLED` | Enable session recording | `true` | No |
@@ -97,6 +101,7 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
# Optional one-time import of legacy users:
- WEBSSH_USERS=user1:pass1,user2:pass2 - WEBSSH_USERS=user1:pass1,user2:pass2
- JWT_SECRET=your-secret-key-here - JWT_SECRET=your-secret-key-here
- MAX_UPLOAD_SIZE=100M - MAX_UPLOAD_SIZE=100M
@@ -105,7 +110,7 @@ services:
## Security Notes ## Security Notes
⚠️ **Important for Production:** ⚠️ **Important for Production:**
- **Change default credentials** - Set strong passwords via `WEBSSH_USERS` - **Create strong user passwords** - The first user is created during setup; imported users are prompted to replace legacy passwords
- **Set a strong `JWT_SECRET`** - Use at least 32 random characters - **Set a strong `JWT_SECRET`** - Use at least 32 random characters
- **Use HTTPS** - Configure reverse proxy with SSL/TLS (container handles HTTP internally) - **Use HTTPS** - Configure reverse proxy with SSL/TLS (container handles HTTP internally)
- **SSH host keys** - Uses `AutoAddPolicy()` by default; configure strict host key checking for production - **SSH host keys** - Uses `AutoAddPolicy()` by default; configure strict host key checking for production
+4 -2
View File
@@ -330,7 +330,9 @@
<li><code>"500K"</code> - 500 kilobytes</li> <li><code>"500K"</code> - 500 kilobytes</li>
</ul> </ul>
<p><strong>WEBSSH_USERS:</strong> Users in format <code>username:password,username2:password2</code></p> <p><strong>WEBSSH_USERS:</strong> Optional one-time import in format <code>username:password,username2:password2</code>. The first imported user becomes an administrator, the rest have the user role, and all imported users must change their password at first login. Remove this variable after the import.</p>
<p>On a fresh installation without imported users, use the first-start form to create the administrator. New passwords must contain at least 12 characters.</p>
<p>Administrators can create and delete accounts using <strong>Manage Users</strong>. New accounts must change their temporary password at first login.</p>
<p><strong>JWT_SECRET:</strong> Secret key for JWT tokens (auto-generated if not set)</p> <p><strong>JWT_SECRET:</strong> Secret key for JWT tokens (auto-generated if not set)</p>
@@ -390,7 +392,7 @@
<p>📖 <strong>Project page:</strong> <a href="https://danaildr.eu/projects/webssh.html" target="_blank" rel="noopener">danaildr/webssh</a></p> <p>📖 <strong>Project page:</strong> <a href="https://danaildr.eu/projects/webssh.html" target="_blank" rel="noopener">danaildr/webssh</a></p>
<p>🐳 <strong>Docker Hub:</strong> <a href="https://hub.docker.com/r/danaildr/webssh" target="_blank" rel="noopener">danaildr/webssh</a></p> <p>🐳 <strong>Docker Hub:</strong> <a href="https://hub.docker.com/r/danaildr/webssh" target="_blank" rel="noopener">danaildr/webssh</a></p>
<p>📧 <strong>Support:</strong> See the <a href="https://danaildr.eu/projects/webssh.html" target="_blank" rel="noopener">project documentation</a> for setup, env vars, and reverse proxy config.</p> <p>📧 <strong>Support:</strong> See the <a href="https://danaildr.eu/projects/webssh.html" target="_blank" rel="noopener">project documentation</a> for setup, env vars, and reverse proxy config.</p>
<p>🔄 <strong>Version:</strong> WebSSH Multi-Connection Terminal v0.5.1</p> <p>🔄 <strong>Version:</strong> WebSSH Multi-Connection Terminal v0.6.0</p>
<p>⚡ <strong>Technologies:</strong> Python, Paramiko, WebSockets, HTML5, CSS3, JavaScript</p> <p>⚡ <strong>Technologies:</strong> Python, Paramiko, WebSockets, HTML5, CSS3, JavaScript</p>
</div> </div>
</body> </body>
+51 -6
View File
@@ -18,19 +18,23 @@
<!-- Login Modal --> <!-- Login Modal -->
<div class="modal-overlay active" id="loginModal"> <div class="modal-overlay active" id="loginModal">
<div class="modal"> <div class="modal">
<h3>WebSSH Login</h3> <h3 id="authTitle">WebSSH Login</h3>
<form onsubmit="event.preventDefault(); login()"> <form onsubmit="event.preventDefault(); login()">
<div class="form-group"> <div class="form-group" id="loginUsernameGroup">
<label>Username</label> <label>Username</label>
<input id="loginUsername" placeholder="admin"> <input id="loginUsername" placeholder="Username" autocomplete="username">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Password</label> <label id="loginPasswordLabel">Password</label>
<input type="password" id="loginPassword" placeholder="Password"> <input type="password" id="loginPassword" placeholder="Password" autocomplete="current-password">
</div>
<div class="form-group" id="confirmPasswordGroup" hidden>
<label>Confirm password</label>
<input type="password" id="confirmPassword" placeholder="Confirm password" autocomplete="new-password">
</div> </div>
<div id="loginError" style="color: #ef4444; font-size: 13px; margin-bottom: 10px;"></div> <div id="loginError" style="color: #ef4444; font-size: 13px; margin-bottom: 10px;"></div>
<div class="modal-actions"> <div class="modal-actions">
<button type="submit">Login</button> <button type="submit" id="authSubmitButton">Login</button>
</div> </div>
</form> </form>
</div> </div>
@@ -38,6 +42,10 @@
<!-- Sidebar --> <!-- Sidebar -->
<div class="sidebar"> <div class="sidebar">
<div class="app-version">
<span id="appVersion"></span>
<span id="versionUpdateNotice" class="version-update-notice" hidden></span>
</div>
<div class="sidebar-header"> <div class="sidebar-header">
<h2>Connections</h2> <h2>Connections</h2>
<div style="display: flex; gap: 5px; margin-bottom: 10px;"> <div style="display: flex; gap: 5px; margin-bottom: 10px;">
@@ -51,11 +59,48 @@
</div> </div>
<button class="btn-add" onclick="showNewConnectionModal()">+ New Connection</button> <button class="btn-add" onclick="showNewConnectionModal()">+ New Connection</button>
<button class="btn-recordings" onclick="showRecordings()" title="Session Recordings">🎥 Recordings</button> <button class="btn-recordings" onclick="showRecordings()" title="Session Recordings">🎥 Recordings</button>
<button id="manageUsersBtn" class="btn-user-management" onclick="showUserManagement()" hidden>Manage Users</button>
</div> </div>
<div class="connection-list" id="connectionList"> <div class="connection-list" id="connectionList">
</div> </div>
</div> </div>
<!-- User Management Modal -->
<div class="modal-overlay" id="userManagementModal">
<div class="modal" style="max-width: 600px; max-height: 85vh; overflow-y: auto;">
<div class="modal-header">
<h3>Manage Users</h3>
<button class="modal-close" onclick="hideUserManagement()">&times;</button>
</div>
<div class="modal-body">
<div id="usersList" class="users-list">Loading users...</div>
<h4>Create User</h4>
<form onsubmit="event.preventDefault(); createUser()">
<div class="form-group">
<label>Username</label>
<input id="newAppUsername" autocomplete="off" required minlength="3" maxlength="64" pattern="[A-Za-z0-9_.-]+">
</div>
<div class="form-group">
<label>Temporary password (at least 12 characters)</label>
<input id="newAppPassword" type="password" autocomplete="new-password" required minlength="12">
</div>
<div class="form-group">
<label>Role</label>
<select id="newAppRole">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div id="userManagementError" class="user-management-error"></div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="hideUserManagement()">Close</button>
<button type="submit">Create User</button>
</div>
</form>
</div>
</div>
</div>
<!-- Main Content --> <!-- Main Content -->
<div class="main-content"> <div class="main-content">
<!-- Terminal View --> <!-- Terminal View -->
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Interactively reset an application's user password."""
import getpass
import sys
from webssh_backend.auth import hash_password
from webssh_backend.database import creds_manager
MIN_PASSWORD_LENGTH = 12
def main() -> int:
if not sys.stdin.isatty() or not sys.stdout.isatty():
print("password-reset requires an interactive terminal.", file=sys.stderr)
return 2
if len(sys.argv) > 2:
print("Usage: password-reset [username]", file=sys.stderr)
return 2
username = sys.argv[1] if len(sys.argv) == 2 else input("Username: ").strip()
if not username:
print("Username is required.", file=sys.stderr)
return 2
try:
user = creds_manager.get_user(username)
if not user:
print(f'User "{username}" does not exist.', file=sys.stderr)
return 1
password = getpass.getpass("New password (minimum 12 characters): ")
if len(password) < MIN_PASSWORD_LENGTH:
print("Password must be at least 12 characters.", file=sys.stderr)
return 2
if len(password.encode("utf-8")) > 1024:
print("Password must not exceed 1024 UTF-8 bytes.", file=sys.stderr)
return 2
confirmation = getpass.getpass("Confirm new password: ")
if password != confirmation:
print("Passwords do not match.", file=sys.stderr)
return 2
if not creds_manager.reset_user_password(username, hash_password(password)):
print(f'Failed to reset password for "{username}".', file=sys.stderr)
return 1
print(f'Password reset for "{username}". They must change it at next login.')
return 0
except (EOFError, KeyboardInterrupt):
print("\nPassword reset cancelled.", file=sys.stderr)
return 130
except Exception as error:
print(f"Password reset failed: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+3 -1
View File
@@ -12,7 +12,9 @@ Usage:
python run.py python run.py
Environment variables: Environment variables:
WEBSSH_USERS - Comma-separated user:pass pairs (default: admin:admin) WEBSSH_USERS - Optional one-time import of comma-separated user:pass pairs.
Imported users must change their password at first login.
If unset and the database has no users, create the first admin in the browser.
JWT_SECRET - Secret key for JWT tokens (auto-generated if not set) JWT_SECRET - Secret key for JWT tokens (auto-generated if not set)
MAX_UPLOAD_SIZE - Maximum file upload size (default: 100M) MAX_UPLOAD_SIZE - Maximum file upload size (default: 100M)
RECORDING_ENABLED - Enable session recording (default: true) RECORDING_ENABLED - Enable session recording (default: true)
+62
View File
@@ -19,6 +19,24 @@ body {
flex-direction: column; flex-direction: column;
} }
.app-version {
padding: 9px 15px;
color: #9ca3af;
font-size: 12px;
background: #111827;
border-bottom: 1px solid #1f2937;
}
.version-update-notice {
display: block;
margin-top: 3px;
color: #fbbf24;
}
.version-update-notice[hidden] {
display: none;
}
.sidebar-header { .sidebar-header {
padding: 15px; padding: 15px;
background: #1f2937; background: #1f2937;
@@ -45,6 +63,50 @@ body {
.btn-add:hover { background: #059669; } .btn-add:hover { background: #059669; }
.btn-user-management {
width: 100%;
margin-top: 8px;
padding: 7px;
background: #374151;
color: #e5e7eb;
border: 1px solid #4b5563;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.btn-user-management:hover { background: #4b5563; }
.users-list {
margin-bottom: 18px;
}
.user-list-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 9px 0;
border-bottom: 1px solid #374151;
}
.user-list-details {
min-width: 0;
overflow-wrap: anywhere;
}
.user-list-role {
margin-left: 6px;
color: #9ca3af;
font-size: 12px;
}
.user-management-error {
color: #f87171;
font-size: 13px;
margin-bottom: 10px;
}
.sidebar-actions { .sidebar-actions {
display: flex; display: flex;
gap: 5px; gap: 5px;
+361 -72
View File
@@ -4,13 +4,16 @@ let connections = [];
let currentConnectionId = null; let currentConnectionId = null;
let authToken = localStorage.getItem('webssh_token'); let authToken = localStorage.getItem('webssh_token');
let currentUser = localStorage.getItem('webssh_user'); let currentUser = localStorage.getItem('webssh_user');
let storedUsers = {}; // Will be loaded from /api/config
let maxUploadSize = localStorage.getItem('maxUploadSize') ? parseInt(localStorage.getItem('maxUploadSize')) : 100 * 1024 * 1024; let maxUploadSize = localStorage.getItem('maxUploadSize') ? parseInt(localStorage.getItem('maxUploadSize')) : 100 * 1024 * 1024;
// Dynamically determine API URL based on current host (same origin since everything is in container) // Dynamically determine API URL based on current host (same origin since everything is in container)
let apiBaseUrl = window.location.origin; let apiBaseUrl = window.location.origin;
let backendWsPort = null; let backendWsPort = null;
let backendWsHost = null; let backendWsHost = null;
let hostStatusTimer = null; let hostStatusTimer = null;
let authScreenMode = 'login';
let currentUserRole = null;
let runningVersion = null;
let availableLatestVersion = null;
// Use the frontend proxy so the browser does not need direct access to the backend port. // Use the frontend proxy so the browser does not need direct access to the backend port.
function getWsUrl() { function getWsUrl() {
@@ -19,6 +22,108 @@ function getWsUrl() {
} }
const CONNECTIONS_KEY = 'webssh_connections'; const CONNECTIONS_KEY = 'webssh_connections';
const LATEST_VERSION_URL = 'https://strixoss.github.io/strixoss/webssh/latest.json';
const LATEST_VERSION_CACHE_KEY = 'webssh_latest_version_check';
const VERSION_CHECK_INTERVAL = 24 * 60 * 60 * 1000;
function parseVersion(version) {
if (typeof version !== 'string' || !/^\d+(?:\.\d+)*$/.test(version)) return null;
return version.split('.').map(Number);
}
function isNewerVersion(latest, current) {
const latestParts = parseVersion(latest);
const currentParts = parseVersion(current);
if (!latestParts || !currentParts) return false;
const partCount = Math.max(latestParts.length, currentParts.length);
for (let i = 0; i < partCount; i++) {
const latestPart = latestParts[i] || 0;
const currentPart = currentParts[i] || 0;
if (latestPart !== currentPart) return latestPart > currentPart;
}
return false;
}
function showLatestVersion(latestVersion, currentVersion) {
runningVersion = currentVersion;
availableLatestVersion = latestVersion;
renderLatestVersionNotice();
}
function renderLatestVersionNotice() {
const notice = document.getElementById('versionUpdateNotice');
if (!notice) return;
if (currentUserRole !== 'admin' || !isNewerVersion(availableLatestVersion, runningVersion)) {
notice.hidden = true;
notice.textContent = '';
return;
}
notice.textContent = `New version available: v${availableLatestVersion}`;
notice.hidden = false;
}
async function loadVersionAndCheckForUpdates() {
const versionLabel = document.getElementById('appVersion');
const notice = document.getElementById('versionUpdateNotice');
try {
const response = await fetch(`${apiBaseUrl}/api/version`);
if (!response.ok) throw new Error(`Version API returned HTTP ${response.status}`);
const data = await response.json();
const currentVersion = data.version;
if (!parseVersion(currentVersion)) throw new Error('Version API returned an invalid version');
versionLabel.textContent = `WebSSH v${currentVersion}`;
let cachedCheck = null;
try {
cachedCheck = JSON.parse(localStorage.getItem(LATEST_VERSION_CACHE_KEY) || 'null');
} catch (error) {
console.warn('Unable to read cached latest version:', error);
}
if (
cachedCheck &&
Number.isFinite(cachedCheck.checkedAt) &&
Date.now() - cachedCheck.checkedAt >= 0 &&
Date.now() - cachedCheck.checkedAt < VERSION_CHECK_INTERVAL &&
parseVersion(cachedCheck.response?.stable?.version)
) {
showLatestVersion(cachedCheck.response.stable.version, currentVersion);
return;
}
try {
const latestResponse = await fetch(LATEST_VERSION_URL, { cache: 'no-store' });
if (!latestResponse.ok) throw new Error(`Latest-version endpoint returned HTTP ${latestResponse.status}`);
const latestData = await latestResponse.json();
const latestVersion = latestData?.stable?.version;
if (!parseVersion(latestVersion)) throw new Error('Latest-version endpoint returned an invalid version');
try {
localStorage.setItem(LATEST_VERSION_CACHE_KEY, JSON.stringify({
checkedAt: Date.now(),
response: latestData
}));
} catch (error) {
console.warn('Unable to cache latest version response:', error);
}
showLatestVersion(latestVersion, currentVersion);
} catch (error) {
console.warn('Failed to check for a new WebSSH version:', error);
if (parseVersion(cachedCheck?.response?.stable?.version)) {
showLatestVersion(cachedCheck.response.stable.version, currentVersion);
}
}
} catch (error) {
console.warn('Failed to load the current WebSSH version:', error);
if (versionLabel) versionLabel.textContent = 'WebSSH';
if (notice) notice.hidden = true;
}
}
// Load connections from server (with encrypted credentials) // Load connections from server (with encrypted credentials)
async function loadSavedConnections() { async function loadSavedConnections() {
@@ -226,6 +331,7 @@ resizeObserver.observe(document.getElementById('terminal'));
if (authToken) { if (authToken) {
verifyToken(); verifyToken();
} }
initializeAuthScreen();
// Load saved connections // Load saved connections
loadSavedConnections(); loadSavedConnections();
@@ -239,32 +345,69 @@ term.onData((data) => {
}); });
async function login() { async function login() {
const username = document.getElementById('loginUsername').value; const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value; const password = document.getElementById('loginPassword').value;
const apiUrl = apiBaseUrl; const confirmPassword = document.getElementById('confirmPassword').value;
if (!username || !password) { if ((authScreenMode !== 'change-password' && !username) || !password) {
document.getElementById('loginError').textContent = 'Please enter username and password'; document.getElementById('loginError').textContent = authScreenMode === 'change-password'
? 'Please enter a new password'
: 'Please enter username and password';
return; return;
} }
if (authScreenMode !== 'login' && password !== confirmPassword) {
document.getElementById('loginError').textContent = 'Passwords do not match';
return;
}
if (authScreenMode !== 'login' && password.length < 12) {
document.getElementById('loginError').textContent = 'Password must be at least 12 characters';
return;
}
try { try {
const response = await fetch(`${apiUrl}/api/login`, { const endpoint = authScreenMode === 'setup'
? '/api/setup'
: authScreenMode === 'change-password'
? '/api/change-password'
: '/api/login';
const headers = { 'Content-Type': 'application/json' };
if (authScreenMode === 'change-password') {
headers.Authorization = `Bearer ${authToken}`;
}
const response = await fetch(`${apiBaseUrl}${endpoint}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers,
body: JSON.stringify({ username, password }) body: JSON.stringify(authScreenMode === 'change-password' ? { password } : { username, password })
}); });
const data = await response.json(); const data = await response.json();
if (response.ok && data.token) { if (response.ok && authScreenMode === 'setup') {
setAuthScreenMode('login');
await login();
return;
}
if (response.ok && authScreenMode === 'change-password' && data.token) {
authToken = data.token; authToken = data.token;
localStorage.setItem('webssh_token', authToken); localStorage.setItem('webssh_token', authToken);
document.getElementById('loginModal').classList.remove('active'); finishLogin(data);
document.getElementById('loggedInUser').textContent = data.username; } else if (response.ok && data.token && data.password_change_required) {
term.writeln(`[Authenticated as ${data.username}]`); authToken = data.token;
loadSavedConnections(); localStorage.setItem('webssh_token', authToken);
setTimeout(loadConfig, 500); // Load configuration after successful login with delay currentUser = data.username;
localStorage.setItem('webssh_user', currentUser);
setUserManagementAccess(null);
setAuthScreenMode('change-password');
document.getElementById('loginPassword').value = '';
document.getElementById('confirmPassword').value = '';
document.getElementById('loginError').textContent = 'Please set a new password to continue.';
} else if (response.ok && data.token) {
authToken = data.token;
localStorage.setItem('webssh_token', authToken);
finishLogin(data);
} else { } else {
document.getElementById('loginError').textContent = data.error || 'Login failed'; document.getElementById('loginError').textContent = data.error || 'Login failed';
} }
@@ -273,6 +416,165 @@ async function login() {
} }
} }
function finishLogin(data) {
currentUser = data.username;
localStorage.setItem('webssh_user', currentUser);
document.getElementById('loginModal').classList.remove('active');
document.getElementById('loggedInUser').textContent = `${data.username} (${data.role})`;
setUserManagementAccess(data.role);
term.writeln(`[Authenticated as ${data.username}]`);
loadSavedConnections();
setTimeout(loadConfig, 500);
}
function setUserManagementAccess(role) {
currentUserRole = role;
document.getElementById('manageUsersBtn').hidden = role !== 'admin';
renderLatestVersionNotice();
}
async function showUserManagement() {
document.getElementById('userManagementModal').classList.add('active');
document.getElementById('userManagementError').textContent = '';
await loadUsers();
}
function hideUserManagement() {
document.getElementById('userManagementModal').classList.remove('active');
}
async function loadUsers() {
const usersList = document.getElementById('usersList');
usersList.textContent = 'Loading users...';
try {
const response = await fetch(`${apiBaseUrl}/api/users`, {
headers: { 'Authorization': 'Bearer ' + authToken }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to load users');
usersList.replaceChildren();
data.users.forEach(user => {
const row = document.createElement('div');
row.className = 'user-list-item';
const details = document.createElement('div');
details.className = 'user-list-details';
const name = document.createElement('strong');
name.textContent = user.username;
const role = document.createElement('span');
role.className = 'user-list-role';
role.textContent = user.role;
details.append(name, role);
if (user.must_change_password) {
const changeNotice = document.createElement('div');
changeNotice.className = 'user-list-role';
changeNotice.textContent = 'Password change required at next login';
details.append(changeNotice);
}
row.append(details);
if (user.username !== currentUser) {
const deleteButton = document.createElement('button');
deleteButton.className = 'btn-small btn-delete';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', () => deleteUser(user.username));
row.append(deleteButton);
}
usersList.append(row);
});
} catch (error) {
usersList.textContent = error.message;
}
}
async function createUser() {
const errorDisplay = document.getElementById('userManagementError');
errorDisplay.textContent = '';
try {
const response = await fetch(`${apiBaseUrl}/api/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + authToken
},
body: JSON.stringify({
username: document.getElementById('newAppUsername').value.trim(),
password: document.getElementById('newAppPassword').value,
role: document.getElementById('newAppRole').value
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to create user');
document.getElementById('newAppUsername').value = '';
document.getElementById('newAppPassword').value = '';
errorDisplay.textContent = 'User created. They must change the temporary password at first login.';
errorDisplay.style.color = '#34d399';
await loadUsers();
} catch (error) {
errorDisplay.style.color = '#f87171';
errorDisplay.textContent = error.message;
}
}
async function deleteUser(username) {
if (!window.confirm(`Delete user "${username}"?`)) return;
const errorDisplay = document.getElementById('userManagementError');
errorDisplay.textContent = '';
errorDisplay.style.color = '#f87171';
try {
const response = await fetch(`${apiBaseUrl}/api/users/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + authToken
},
body: JSON.stringify({ username })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to delete user');
await loadUsers();
} catch (error) {
errorDisplay.textContent = error.message;
}
}
function setAuthScreenMode(mode) {
authScreenMode = mode;
const isLogin = mode === 'login';
const isSetup = mode === 'setup';
const title = document.getElementById('authTitle');
const usernameGroup = document.getElementById('loginUsernameGroup');
const passwordLabel = document.getElementById('loginPasswordLabel');
const confirmGroup = document.getElementById('confirmPasswordGroup');
const submitButton = document.getElementById('authSubmitButton');
const username = document.getElementById('loginUsername');
const password = document.getElementById('loginPassword');
title.textContent = isSetup ? 'Create Administrator' : isLogin ? 'WebSSH Login' : 'Change Password';
usernameGroup.hidden = mode === 'change-password';
passwordLabel.textContent = isLogin ? 'Password' : 'New password';
confirmGroup.hidden = isLogin;
submitButton.textContent = isSetup ? 'Create administrator' : isLogin ? 'Login' : 'Change password';
username.autocomplete = 'username';
password.autocomplete = isLogin ? 'current-password' : 'new-password';
password.placeholder = isLogin ? 'Password' : 'At least 12 characters';
document.getElementById('loginError').textContent = '';
}
async function initializeAuthScreen() {
try {
const response = await fetch(`${apiBaseUrl}/api/setup-status`);
if (!response.ok) throw new Error(`Setup status returned HTTP ${response.status}`);
const data = await response.json();
if (data.needs_setup && !authToken) {
setAuthScreenMode('setup');
}
} catch (error) {
console.error('Unable to determine initial setup status:', error);
}
}
async function verifyToken() { async function verifyToken() {
try { try {
const response = await fetch(`${apiBaseUrl}/api/verify`, { const response = await fetch(`${apiBaseUrl}/api/verify`, {
@@ -280,27 +582,37 @@ async function verifyToken() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: authToken }) body: JSON.stringify({ token: authToken })
}); });
const data = await response.json(); const data = await response.json();
if (data.valid) { if (data.valid && data.password_change_required) {
currentUser = data.username;
setUserManagementAccess(null);
setAuthScreenMode('change-password');
document.getElementById('loginModal').classList.add('active');
document.getElementById('loginError').textContent = 'Please set a new password to continue.';
} else if (data.valid) {
document.getElementById('loginModal').classList.remove('active'); document.getElementById('loginModal').classList.remove('active');
document.getElementById('loggedInUser').textContent = data.username; document.getElementById('loggedInUser').textContent = `${data.username} (${data.role})`;
setUserManagementAccess(data.role);
term.writeln(`[Authenticated as ${data.username}]`); term.writeln(`[Authenticated as ${data.username}]`);
loadSavedConnections(); // Load encrypted credentials from server loadSavedConnections();
setTimeout(loadConfig, 500); // Load configuration after successful token verification with delay setTimeout(loadConfig, 500);
} else { } else {
localStorage.removeItem('webssh_token'); localStorage.removeItem('webssh_token');
authToken = null; authToken = null;
setUserManagementAccess(null);
} }
} catch (e) { } catch (e) {
localStorage.removeItem('webssh_token'); localStorage.removeItem('webssh_token');
authToken = null; authToken = null;
setUserManagementAccess(null);
} }
} }
function logout() { function logout() {
localStorage.removeItem('webssh_token'); localStorage.removeItem('webssh_token');
localStorage.removeItem('webssh_user');
localStorage.removeItem('webssh_api_url'); localStorage.removeItem('webssh_api_url');
authToken = null; authToken = null;
location.reload(); location.reload();
@@ -816,6 +1128,7 @@ function sendResizeForConnection(conn) {
// Initialize // Initialize
toggleAuthFields(); toggleAuthFields();
loadVersionAndCheckForUpdates();
term.writeln('WebSSH Multi-Connection Client'); term.writeln('WebSSH Multi-Connection Client');
term.writeln('Click "+ New Connection" to get started'); term.writeln('Click "+ New Connection" to get started');
@@ -862,11 +1175,6 @@ function loadConfig() {
backendWsHost = config.config.ws_host || window.location.hostname || 'localhost'; backendWsHost = config.config.ws_host || window.location.hostname || 'localhost';
console.log(`Loaded backend WebSocket port: ${backendWsPort}`); console.log(`Loaded backend WebSocket port: ${backendWsPort}`);
} }
// Load stored users for lock screen verification
if (config.config.stored_users) {
storedUsers = config.config.stored_users;
console.log('Loaded stored users for lock screen');
}
// Load current user // Load current user
if (config.config.current_user) { if (config.config.current_user) {
currentUser = config.config.current_user; currentUser = config.config.current_user;
@@ -2505,52 +2813,33 @@ async function unlockSession() {
return; return;
} }
// Hash the password and verify against stored users try {
const hashedInput = await hashPassword(password); const response = await fetch(`${apiBaseUrl}/api/verify-password`, {
// Try to find a matching user
let validUser = null;
for (const [username, storedHash] of Object.entries(storedUsers || {})) {
if (storedHash === hashedInput) {
validUser = username;
break;
}
}
if (!validUser) {
// Also try current session
const response = await fetch(`${apiBaseUrl}/api/verify`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
body: JSON.stringify({ token: authToken }) 'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ password })
}); });
const result = await response.json();
if (response.ok) {
validUser = currentUser;
}
}
if (validUser) {
isLocked = false;
lastActivityTime = Date.now();
document.getElementById('lockScreenModal').style.display = 'none';
resetInactivityTimers();
console.log('Session unlocked');
} else {
document.getElementById('unlockError').textContent = 'Incorrect password';
document.getElementById('unlockError').style.display = 'block';
document.getElementById('unlockPassword').value = '';
document.getElementById('unlockPassword').focus();
}
}
// Hash password for verification if (response.ok && result.valid) {
async function hashPassword(password) { isLocked = false;
const encoder = new TextEncoder(); lastActivityTime = Date.now();
const data = encoder.encode(password); document.getElementById('lockScreenModal').style.display = 'none';
const hashBuffer = await crypto.subtle.digest('SHA-256', data); resetInactivityTimers();
const hashArray = Array.from(new Uint8Array(hashBuffer)); console.log('Session unlocked');
return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); return;
}
} catch (error) {
console.error('Password verification failed:', error);
}
document.getElementById('unlockError').textContent = 'Incorrect password';
document.getElementById('unlockError').style.display = 'block';
document.getElementById('unlockPassword').value = '';
document.getElementById('unlockPassword').focus();
} }
// Auto-disconnect due to inactivity // Auto-disconnect due to inactivity
+1 -1
View File
@@ -18,7 +18,7 @@ Usage:
from webssh_backend.main import main from webssh_backend.main import main
asyncio.run(main()) asyncio.run(main())
""" """
__version__ = "0.5.1" __version__ = "0.6.0"
# Note: Specific imports are intentionally NOT done at package level # Note: Specific imports are intentionally NOT done at package level
# to avoid loading all dependencies when only specific modules are needed. # to avoid loading all dependencies when only specific modules are needed.
+82 -16
View File
@@ -1,6 +1,10 @@
""" """
Auth module - JWT authentication and user management. Auth module - JWT authentication and user management.
""" """
import base64
import hashlib
import hmac
import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@@ -8,11 +12,49 @@ from urllib.parse import parse_qs, urlparse
import jwt import jwt
from websockets.server import WebSocketServerProtocol from websockets.server import WebSocketServerProtocol
from .config import JWT_ALGORITHM, JWT_EXPIRE_HOURS, JWT_SECRET, USERS, logger from .config import JWT_ALGORITHM, JWT_EXPIRE_HOURS, JWT_SECRET, logger
__all__ = ['verify_token', 'verify_token_ws', 'verify_token_http', 'generate_token', 'JWT_ALGORITHM', 'JWT_EXPIRE_HOURS', 'JWT_SECRET', 'USERS'] __all__ = [
'verify_token', 'verify_token_ws', 'verify_token_http', 'generate_token',
'hash_password', 'verify_password', 'decode_token',
'JWT_ALGORITHM', 'JWT_EXPIRE_HOURS', 'JWT_SECRET'
]
from .utils import get_client_ip from .utils import get_client_ip
PASSWORD_HASH_ITERATIONS = 600_000
def hash_password(password: str) -> str:
"""Hash a password using PBKDF2-HMAC-SHA256 with a unique random salt."""
salt = os.urandom(16)
digest = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, PASSWORD_HASH_ITERATIONS)
return 'pbkdf2_sha256${}${}${}'.format(
PASSWORD_HASH_ITERATIONS,
base64.urlsafe_b64encode(salt).decode('ascii'),
base64.urlsafe_b64encode(digest).decode('ascii')
)
def verify_password(password: str, stored_hash: str) -> bool:
"""Constant-time verify a password against a stored PBKDF2 hash."""
try:
algorithm, iterations, salt, expected = stored_hash.split('$', 3)
iterations = int(iterations)
if algorithm != 'pbkdf2_sha256' or not 100_000 <= iterations <= 2_000_000:
return False
digest = hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
base64.urlsafe_b64decode(salt.encode('ascii')),
iterations
)
return hmac.compare_digest(
base64.urlsafe_b64encode(digest).decode('ascii'),
expected
)
except (AttributeError, TypeError, ValueError):
return False
async def verify_token_ws(ws: WebSocketServerProtocol) -> Optional[str]: async def verify_token_ws(ws: WebSocketServerProtocol) -> Optional[str]:
"""Verify JWT token from WebSocket query parameters. Returns username or None.""" """Verify JWT token from WebSocket query parameters. Returns username or None."""
@@ -27,8 +69,19 @@ async def verify_token_ws(ws: WebSocketServerProtocol) -> Optional[str]:
logger.warning(f"No token provided from {get_client_ip(ws)}") logger.warning(f"No token provided from {get_client_ip(ws)}")
return None return None
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) payload = decode_token(token)
return payload['sub'] if not payload or payload.get('pwd_change'):
return None
from .database import creds_manager
user = creds_manager.get_user(payload.get('sub'))
if (
not user
or user['must_change_password']
or payload.get('auth_version', 0) != user['auth_version']
):
return None
return user['username']
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
logger.warning(f"Expired token from {get_client_ip(ws)}") logger.warning(f"Expired token from {get_client_ip(ws)}")
return None return None
@@ -40,29 +93,42 @@ async def verify_token_ws(ws: WebSocketServerProtocol) -> Optional[str]:
return None return None
def generate_token(username: str) -> str: def generate_token(
username: str,
password_change_required: bool = False,
auth_version: int = 0,
) -> str:
"""Generate a new JWT token for the user.""" """Generate a new JWT token for the user."""
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS) expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
token = jwt.encode( payload = {
{'sub': username, 'exp': expire, 'iat': datetime.now(timezone.utc)}, 'sub': username,
JWT_SECRET, 'exp': expire,
algorithm=JWT_ALGORITHM 'iat': datetime.now(timezone.utc),
) 'auth_version': auth_version,
}
if password_change_required:
payload['pwd_change'] = True
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
# Handle both PyJWT v1 (bytes) and v2 (str) # Handle both PyJWT v1 (bytes) and v2 (str)
if isinstance(token, bytes): if isinstance(token, bytes):
token = token.decode('utf-8') token = token.decode('utf-8')
return token return token
def verify_token_http(token: str) -> Optional[str]: def decode_token(token: str) -> Optional[dict]:
"""Verify JWT token from HTTP request. Returns username or None.""" """Decode a JWT token, returning None when invalid or expired."""
try: try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload.get('sub') except (jwt.InvalidTokenError, TypeError):
except jwt.ExpiredSignatureError:
return None return None
except jwt.InvalidTokenError:
def verify_token_http(token: str, allow_password_change: bool = False) -> Optional[str]:
"""Verify an HTTP JWT, rejecting password-change-only tokens by default."""
payload = decode_token(token)
if not payload or (payload.get('pwd_change') and not allow_password_change):
return None return None
return payload.get('sub')
# For backward compatibility, keep the original function name # For backward compatibility, keep the original function name
+9 -10
View File
@@ -1,7 +1,6 @@
""" """
Configuration module - all constants and environment variables. Configuration module - all constants and environment variables.
""" """
import hashlib
import json import json
import logging import logging
import os import os
@@ -201,16 +200,16 @@ except Exception as exc:
logger.info(f"Resolved backend ports: ws={WS_PORT}, http={HTTP_PORT}, sftp={SFTP_WS_PORT}") logger.info(f"Resolved backend ports: ws={WS_PORT}, http={HTTP_PORT}, sftp={SFTP_WS_PORT}")
# User Configuration (username: password_hash) # Legacy user configuration is imported into the database once during startup.
# Set via environment variable: WEBSSH_USERS=user1:pass1,user2:pass2 # Format: WEBSSH_USERS=user1:pass1,user2:pass2
USERS = {} legacy_users = {}
users_env = os.environ.get("WEBSSH_USERS", "admin:admin") for user_cred in os.environ.get("WEBSSH_USERS", "").split(","):
for user_cred in users_env.split(","):
if ":" in user_cred: if ":" in user_cred:
u, p = user_cred.split(":", 1) username, password = user_cred.split(":", 1)
USERS[u.strip()] = hashlib.sha256(p.strip().encode()).hexdigest() username, password = username.strip(), password.strip()
if username and password:
logger.info(f"Loaded {len(USERS)} users: {list(USERS.keys())}") legacy_users[username] = password
LEGACY_USERS = list(legacy_users.items())
# Log storage directory status # Log storage directory status
logger.info(f"Using storage directory: {STORAGE_DIR}") logger.info(f"Using storage directory: {STORAGE_DIR}")
+221 -1
View File
@@ -7,7 +7,8 @@ from typing import Any, Dict, List, Optional
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from .config import ENCRYPTION_KEY, STORAGE_FILE, logger from .config import ENCRYPTION_KEY, LEGACY_USERS, STORAGE_FILE, logger
from .auth import hash_password
class CredentialsManager: class CredentialsManager:
@@ -101,10 +102,229 @@ class CredentialsManager:
renewal_request_id TEXT renewal_request_id TEXT
) )
''') ''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
must_change_password INTEGER NOT NULL DEFAULT 0,
auth_version INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS schema_migrations (
migration_id TEXT PRIMARY KEY,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
''')
user_columns = {
row[1] for row in cursor.execute('PRAGMA table_info(users)').fetchall()
}
if 'auth_version' not in user_columns:
cursor.execute('ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0')
cursor.execute(
"INSERT OR IGNORE INTO schema_migrations (migration_id) VALUES (?)",
('003_user_auth_version',)
)
cursor.execute(
"INSERT OR IGNORE INTO schema_migrations (migration_id) VALUES (?)",
('001_users_table',)
)
conn.commit() conn.commit()
conn.close() conn.close()
self._import_legacy_users()
logger.info(f"Credentials database initialized at {self.db_path}") logger.info(f"Credentials database initialized at {self.db_path}")
def _import_legacy_users(self):
"""Import environment users once; legacy passwords must be changed at first login."""
conn = sqlite3.connect(self.db_path)
try:
conn.execute('BEGIN IMMEDIATE')
already_imported = conn.execute(
'SELECT 1 FROM schema_migrations WHERE migration_id = ?',
('002_legacy_users_import',)
).fetchone()
if already_imported:
conn.commit()
return
user_count = conn.execute('SELECT COUNT(*) FROM users').fetchone()[0]
imported_count = 0
if user_count == 0:
for index, (username, password) in enumerate(LEGACY_USERS):
conn.execute('''
INSERT INTO users (username, password_hash, role, must_change_password)
VALUES (?, ?, ?, 1)
''', (
username,
hash_password(password),
'admin' if index == 0 else 'user'
))
imported_count += 1
conn.execute(
'INSERT INTO schema_migrations (migration_id) VALUES (?)',
('002_legacy_users_import',)
)
conn.commit()
if imported_count:
logger.info(f"Imported {imported_count} legacy user(s); password change required at next login")
except Exception:
conn.rollback()
raise
finally:
conn.close()
def get_user(self, username: str) -> Optional[Dict[str, Any]]:
"""Return a user record without exposing it to client responses."""
conn = sqlite3.connect(self.db_path)
try:
row = conn.execute('''
SELECT username, password_hash, role, must_change_password, created_at, auth_version
FROM users WHERE username = ?
''', (username,)).fetchone()
if not row:
return None
return {
'username': row[0],
'password_hash': row[1],
'role': row[2],
'must_change_password': bool(row[3]),
'created_at': row[4],
'auth_version': row[5],
}
finally:
conn.close()
def has_users(self) -> bool:
conn = sqlite3.connect(self.db_path)
try:
return conn.execute('SELECT 1 FROM users LIMIT 1').fetchone() is not None
finally:
conn.close()
def list_users(self) -> List[Dict[str, Any]]:
conn = sqlite3.connect(self.db_path)
try:
rows = conn.execute('''
SELECT username, role, must_change_password, created_at
FROM users ORDER BY username COLLATE NOCASE
''').fetchall()
return [
{
'username': row[0],
'role': row[1],
'must_change_password': bool(row[2]),
'created_at': row[3],
}
for row in rows
]
finally:
conn.close()
def create_user(self, username: str, password_hash: str, role: str) -> bool:
conn = sqlite3.connect(self.db_path)
try:
conn.execute('''
INSERT INTO users (username, password_hash, role, must_change_password)
VALUES (?, ?, ?, 1)
''', (username, password_hash, role))
conn.commit()
return True
except sqlite3.IntegrityError:
conn.rollback()
return False
except Exception:
conn.rollback()
raise
finally:
conn.close()
def delete_user(self, username: str, requested_by: str) -> str:
"""Delete a user without permitting self-deletion or removal of the last admin."""
if username == requested_by:
return 'self'
conn = sqlite3.connect(self.db_path)
try:
conn.execute('BEGIN IMMEDIATE')
row = conn.execute('SELECT role FROM users WHERE username = ?', (username,)).fetchone()
if not row:
conn.rollback()
return 'not_found'
if row[0] == 'admin':
admin_count = conn.execute(
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
).fetchone()[0]
if admin_count <= 1:
conn.rollback()
return 'last_admin'
conn.execute('DELETE FROM users WHERE username = ?', (username,))
conn.commit()
return 'deleted'
except Exception:
conn.rollback()
raise
finally:
conn.close()
def create_first_admin(self, username: str, password_hash: str) -> bool:
"""Atomically create the initial administrator only when no users exist."""
conn = sqlite3.connect(self.db_path)
try:
conn.execute('BEGIN IMMEDIATE')
if conn.execute('SELECT 1 FROM users LIMIT 1').fetchone():
conn.rollback()
return False
conn.execute('''
INSERT INTO users (username, password_hash, role, must_change_password)
VALUES (?, ?, 'admin', 0)
''', (username, password_hash))
conn.commit()
return True
except Exception:
conn.rollback()
raise
finally:
conn.close()
def update_user_password(self, username: str, password_hash: str) -> bool:
conn = sqlite3.connect(self.db_path)
try:
cursor = conn.execute('''
UPDATE users
SET password_hash = ?, must_change_password = 0,
auth_version = auth_version + 1, updated_at = CURRENT_TIMESTAMP
WHERE username = ?
''', (password_hash, username))
conn.commit()
return cursor.rowcount == 1
except Exception:
conn.rollback()
raise
finally:
conn.close()
def reset_user_password(self, username: str, password_hash: str) -> bool:
"""Set a temporary password and invalidate all previously issued tokens."""
conn = sqlite3.connect(self.db_path)
try:
cursor = conn.execute('''
UPDATE users
SET password_hash = ?, must_change_password = 1,
auth_version = auth_version + 1, updated_at = CURRENT_TIMESTAMP
WHERE username = ?
''', (password_hash, username))
conn.commit()
return cursor.rowcount == 1
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _encrypt(self, data: str) -> bytes: def _encrypt(self, data: str) -> bytes:
"""Encrypt string data.""" """Encrypt string data."""
if not data: if not data:
+258 -29
View File
@@ -1,10 +1,10 @@
""" """
HTTP API module - LoginHandler with all HTTP endpoints. HTTP API module - LoginHandler with all HTTP endpoints.
""" """
import hashlib
import io import io
import json import json
import os import os
import re
import socket import socket
import stat import stat
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -13,12 +13,18 @@ from http.server import BaseHTTPRequestHandler
from typing import Optional from typing import Optional
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
import jwt
import paramiko import paramiko
from .auth import JWT_ALGORITHM, JWT_EXPIRE_HOURS, JWT_SECRET, USERS, generate_token, verify_token_http from .auth import (
JWT_EXPIRE_HOURS,
decode_token,
generate_token,
hash_password,
verify_password,
)
from .config import HTTP_PORT, MAX_UPLOAD_SIZE, SFTP_CHUNK_SIZE, WS_PORT, logger from .config import HTTP_PORT, MAX_UPLOAD_SIZE, SFTP_CHUNK_SIZE, WS_PORT, logger
from .database import creds_manager from .database import creds_manager
from . import __version__
APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATIC_DIR = os.path.join(APP_ROOT, 'static') STATIC_DIR = os.path.join(APP_ROOT, 'static')
@@ -40,7 +46,17 @@ class LoginHandler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
parsed = urlparse(self.path) parsed = urlparse(self.path)
if parsed.path == '/api/login': if parsed.path == '/api/setup':
self.handle_initial_setup()
elif parsed.path == '/api/change-password':
self.handle_change_password()
elif parsed.path == '/api/verify-password':
self.handle_verify_password()
elif parsed.path == '/api/users':
self.handle_create_user()
elif parsed.path == '/api/users/delete':
self.handle_delete_user()
elif parsed.path == '/api/login':
self.handle_login() self.handle_login()
elif parsed.path == '/api/verify': elif parsed.path == '/api/verify':
self.handle_verify() self.handle_verify()
@@ -74,6 +90,17 @@ class LoginHandler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
parsed = urlparse(self.path) parsed = urlparse(self.path)
if parsed.path == '/api/setup-status':
self.send_json_response(200, {'needs_setup': not creds_manager.has_users()})
return
if parsed.path == '/api/users':
self.handle_get_users()
return
if parsed.path == '/api/version':
self.send_json_response(200, {'version': __version__})
return
# API endpoint for connections # API endpoint for connections
if parsed.path == '/api/connections': if parsed.path == '/api/connections':
self.handle_get_connections() self.handle_get_connections()
@@ -160,14 +187,106 @@ class LoginHandler(BaseHTTPRequestHandler):
logger.error(f"Error serving file {full_path}: {e}") logger.error(f"Error serving file {full_path}: {e}")
self.send_error(500) self.send_error(500)
def _get_username_from_token(self) -> Optional[str]: def _get_username_from_token(self, allow_password_change: bool = False) -> Optional[str]:
"""Extract username from JWT token in Authorization header.""" """Extract username from JWT token in Authorization header."""
auth_header = self.headers.get('Authorization', '') auth_header = self.headers.get('Authorization', '')
if auth_header.startswith('Bearer '): if auth_header.startswith('Bearer '):
token = auth_header[7:] payload = decode_token(auth_header[7:])
return verify_token_http(token) if not payload:
return None
username = payload.get('sub')
user = creds_manager.get_user(username) if username else None
if not user:
return None
token_requires_password_change = bool(payload.get('pwd_change'))
if token_requires_password_change:
if payload.get('auth_version', 0) != user['auth_version']:
return None
return username if allow_password_change and user['must_change_password'] else None
if payload.get('auth_version', 0) != user['auth_version']:
return None
if user['must_change_password'] and not allow_password_change:
return None
return username
return None return None
def _require_admin(self) -> Optional[str]:
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return None
user = creds_manager.get_user(username)
if not user or user['role'] != 'admin':
self.send_json_response(403, {'error': 'Administrator role required'})
return None
return username
def handle_get_users(self):
if not self._require_admin():
return
try:
self.send_json_response(200, {'users': creds_manager.list_users()})
except Exception as e:
logger.error(f"Get users error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_create_user(self):
if not self._require_admin():
return
try:
data = self._read_json_body()
username = data.get('username', '')
password = data.get('password', '')
role = data.get('role', '')
if not isinstance(username, str):
self.send_json_response(400, {'error': 'Username must be a string'})
return
username = username.strip()
if not self._valid_username(username):
self.send_json_response(400, {'error': 'Username must be 3-64 characters: letters, numbers, ., _ or -'})
return
if role not in ('admin', 'user'):
self.send_json_response(400, {'error': 'Role must be admin or user'})
return
if not self._valid_new_password(password):
self.send_json_response(400, {'error': 'Password must be at least 12 characters'})
return
if not creds_manager.create_user(username, hash_password(password), role):
self.send_json_response(409, {'error': 'Username already exists'})
return
logger.info(f"Created {role} account")
self.send_json_response(201, {'success': True})
except (json.JSONDecodeError, TypeError, ValueError):
self.send_json_response(400, {'error': 'Invalid request'})
except Exception as e:
logger.error(f"Create user error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_delete_user(self):
requested_by = self._require_admin()
if not requested_by:
return
try:
data = self._read_json_body()
username = data.get('username', '')
if not isinstance(username, str) or not username:
self.send_json_response(400, {'error': 'Username is required'})
return
result = creds_manager.delete_user(username, requested_by)
if result == 'deleted':
self.send_json_response(200, {'success': True})
elif result == 'not_found':
self.send_json_response(404, {'error': 'User not found'})
else:
message = 'You cannot delete your own account' if result == 'self' else 'The last administrator cannot be deleted'
self.send_json_response(409, {'error': message})
except (json.JSONDecodeError, TypeError, ValueError):
self.send_json_response(400, {'error': 'Invalid request'})
except Exception as e:
logger.error(f"Delete user error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_save_connection(self): def handle_save_connection(self):
"""Save or update a connection.""" """Save or update a connection."""
username = self._get_username_from_token() username = self._get_username_from_token()
@@ -252,8 +371,8 @@ class LoginHandler(BaseHTTPRequestHandler):
config = { config = {
'max_upload_size': MAX_UPLOAD_SIZE, 'max_upload_size': MAX_UPLOAD_SIZE,
'max_upload_size_mb': MAX_UPLOAD_SIZE / 1024 / 1024, 'max_upload_size_mb': MAX_UPLOAD_SIZE / 1024 / 1024,
'stored_users': USERS, # Include hashed passwords for client-side verification
'current_user': username, 'current_user': username,
'current_role': creds_manager.get_user(username)['role'],
'ws_port': WS_PORT, 'ws_port': WS_PORT,
'http_port': HTTP_PORT, 'http_port': HTTP_PORT,
'sftp_ws_port': SFTP_CHUNK_SIZE 'sftp_ws_port': SFTP_CHUNK_SIZE
@@ -290,32 +409,42 @@ class LoginHandler(BaseHTTPRequestHandler):
def handle_login(self): def handle_login(self):
try: try:
content_length = int(self.headers.get('Content-Length', 0)) data = self._read_json_body()
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
username = data.get('username', '').strip() username = data.get('username', '')
password = data.get('password', '') password = data.get('password', '')
if not isinstance(username, str) or not isinstance(password, str):
self.send_json_response(400, {'error': 'Username and password must be strings'})
return
username = username.strip()
if not username or not password: if not username or not password:
self.send_json_response(400, {'error': 'Username and password required'}) self.send_json_response(400, {'error': 'Username and password required'})
return return
# Check credentials user = creds_manager.get_user(username)
password_hash = hashlib.sha256(password.encode()).hexdigest() if not user or not verify_password(password, user['password_hash']):
if USERS.get(username) != password_hash:
logger.warning(f"Failed login attempt for user: {username}") logger.warning(f"Failed login attempt for user: {username}")
self.send_json_response(401, {'error': 'Invalid credentials'}) self.send_json_response(401, {'error': 'Invalid credentials'})
return return
# Generate JWT token must_change_password = user['must_change_password']
token = generate_token(username) token = generate_token(
username,
password_change_required=must_change_password,
auth_version=user['auth_version'],
)
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS) expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
logger.info(f"Successful login for user: {username}") logger.info(f"Successful login for user: {username}")
self.send_json_response(200, {'token': token, 'username': username, 'expires': expire.isoformat()}) self.send_json_response(200, {
'token': token,
'username': username,
'role': user['role'],
'password_change_required': must_change_password,
'expires': expire.isoformat()
})
except json.JSONDecodeError: except (json.JSONDecodeError, TypeError, ValueError):
self.send_json_response(400, {'error': 'Invalid JSON'}) self.send_json_response(400, {'error': 'Invalid JSON'})
except Exception as e: except Exception as e:
logger.error(f"Login error: {e}") logger.error(f"Login error: {e}")
@@ -323,22 +452,122 @@ class LoginHandler(BaseHTTPRequestHandler):
def handle_verify(self): def handle_verify(self):
try: try:
content_length = int(self.headers.get('Content-Length', 0)) data = self._read_json_body()
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
token = data.get('token', '') token = data.get('token', '')
if not isinstance(token, str):
self.send_json_response(400, {'valid': False, 'error': 'Invalid token'})
return
try: payload = decode_token(token)
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) user = creds_manager.get_user(payload.get('sub')) if payload else None
self.send_json_response(200, {'valid': True, 'username': payload['sub']}) if not user:
except jwt.ExpiredSignatureError:
self.send_json_response(401, {'valid': False, 'error': 'Token expired'})
except jwt.InvalidTokenError:
self.send_json_response(401, {'valid': False, 'error': 'Invalid token'}) self.send_json_response(401, {'valid': False, 'error': 'Invalid token'})
return
if payload.get('auth_version', 0) != user['auth_version']:
self.send_json_response(401, {'valid': False, 'error': 'Invalid token'})
return
if payload.get('pwd_change') and not user['must_change_password']:
self.send_json_response(401, {'valid': False, 'error': 'Invalid token'})
return
password_change_required = user['must_change_password'] or bool(payload.get('pwd_change'))
self.send_json_response(200, {
'valid': True,
'username': user['username'],
'role': user['role'],
'password_change_required': password_change_required
})
except (json.JSONDecodeError, TypeError, ValueError):
self.send_json_response(400, {'valid': False, 'error': 'Invalid request'})
except Exception as e: except Exception as e:
logger.error(f"Verify error: {e}") logger.error(f"Verify error: {e}")
self.send_json_response(500, {'error': 'Internal error'}) self.send_json_response(500, {'error': 'Internal error'})
def handle_initial_setup(self):
try:
data = self._read_json_body()
username = data.get('username', '').strip()
password = data.get('password', '')
if not self._valid_username(username):
self.send_json_response(400, {'error': 'Username must be 3-64 characters: letters, numbers, ., _ or -'})
return
if not self._valid_new_password(password):
self.send_json_response(400, {'error': 'Password must be at least 12 characters'})
return
if not creds_manager.create_first_admin(username, hash_password(password)):
self.send_json_response(409, {'error': 'Initial administrator has already been created'})
return
logger.info("Initial administrator account created")
self.send_json_response(201, {'success': True})
except (json.JSONDecodeError, TypeError, ValueError):
self.send_json_response(400, {'error': 'Invalid request'})
except Exception as e:
logger.error(f"Initial setup error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_change_password(self):
username = self._get_username_from_token(allow_password_change=True)
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
data = self._read_json_body()
password = data.get('password', '')
if not self._valid_new_password(password):
self.send_json_response(400, {'error': 'Password must be at least 12 characters'})
return
if not creds_manager.update_user_password(username, hash_password(password)):
self.send_json_response(404, {'error': 'User not found'})
return
user = creds_manager.get_user(username)
token = generate_token(username, auth_version=user['auth_version'])
logger.info(f"Password changed for user {username}")
self.send_json_response(200, {
'success': True,
'token': token,
'username': username,
'role': user['role']
})
except (json.JSONDecodeError, TypeError, ValueError):
self.send_json_response(400, {'error': 'Invalid request'})
except Exception as e:
logger.error(f"Password change error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_verify_password(self):
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
data = self._read_json_body()
user = creds_manager.get_user(username)
valid = bool(user and verify_password(data.get('password', ''), user['password_hash']))
self.send_json_response(200, {'valid': valid})
except (json.JSONDecodeError, TypeError, ValueError):
self.send_json_response(400, {'error': 'Invalid request'})
except Exception as e:
logger.error(f"Password verification error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def _read_json_body(self):
content_length = int(self.headers.get('Content-Length', 0))
data = json.loads(self.rfile.read(content_length).decode('utf-8'))
if not isinstance(data, dict):
raise ValueError('JSON body must be an object')
return data
@staticmethod
def _valid_username(username: str) -> bool:
return isinstance(username, str) and re.fullmatch(r'[A-Za-z0-9_.-]{3,64}', username) is not None
@staticmethod
def _valid_new_password(password: str) -> bool:
return isinstance(password, str) and len(password) >= 12 and len(password.encode('utf-8')) <= 1024
def send_json_response(self, status, data): def send_json_response(self, status, data):
self.send_response(status) self.send_response(status)
self.send_header('Content-Type', 'application/json') self.send_header('Content-Type', 'application/json')
+329
View File
@@ -0,0 +1,329 @@
import io
import json
import os
import sqlite3
import tempfile
import threading
import unittest
from http.server import HTTPServer
import urllib.error
import urllib.request
from unittest.mock import patch
from cryptography.fernet import Fernet
import webssh_backend.database as database
import webssh_backend.http_api as http_api
import password_reset
from webssh_backend.auth import (
generate_token,
hash_password,
verify_password,
verify_token_http,
)
from webssh_backend.database import CredentialsManager
class PasswordHashTests(unittest.TestCase):
def test_password_hash_uses_salt_and_verifies_password(self):
first_hash = hash_password("a-long-password")
second_hash = hash_password("a-long-password")
self.assertNotEqual(first_hash, second_hash)
self.assertTrue(verify_password("a-long-password", first_hash))
self.assertFalse(verify_password("wrong-password", first_hash))
self.assertFalse(verify_password("a-long-password", "invalid-hash"))
def test_password_change_token_is_limited_to_password_change(self):
token = generate_token("user", password_change_required=True)
self.assertIsNone(verify_token_http(token))
self.assertEqual(verify_token_http(token, allow_password_change=True), "user")
class UserDatabaseTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.temp_dir.name, "credentials.db")
def create_manager(self, users):
with patch.object(database, "LEGACY_USERS", users):
return CredentialsManager(self.db_path, Fernet.generate_key().decode())
def test_legacy_users_are_imported_once_with_forced_password_change(self):
manager = self.create_manager([
("owner", "legacy-owner-password"),
("operator", "legacy-user-password"),
])
owner = manager.get_user("owner")
operator = manager.get_user("operator")
self.assertEqual(owner["role"], "admin")
self.assertEqual(operator["role"], "user")
self.assertTrue(owner["must_change_password"])
self.assertTrue(operator["must_change_password"])
self.assertTrue(verify_password("legacy-owner-password", owner["password_hash"]))
with patch.object(database, "LEGACY_USERS", [("intruder", "another-password")]):
CredentialsManager(self.db_path, Fernet.generate_key().decode())
self.assertIsNone(manager.get_user("intruder"))
def test_existing_user_table_is_migrated_with_auth_version(self):
conn = sqlite3.connect(self.db_path)
conn.execute('''
CREATE TABLE users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
''')
conn.execute('''
CREATE TABLE schema_migrations (
migration_id TEXT PRIMARY KEY,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
''')
conn.execute(
"INSERT INTO schema_migrations (migration_id) VALUES ('001_users_table')"
)
conn.execute(
"INSERT INTO schema_migrations (migration_id) VALUES ('002_legacy_users_import')"
)
conn.execute(
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
("existing", hash_password("existing-account-password"), "admin"),
)
conn.commit()
conn.close()
manager = self.create_manager([])
user = manager.get_user("existing")
self.assertEqual(user["auth_version"], 0)
self.assertEqual(user["role"], "admin")
conn = sqlite3.connect(self.db_path)
migration = conn.execute(
"SELECT 1 FROM schema_migrations WHERE migration_id = '003_user_auth_version'"
).fetchone()
conn.close()
self.assertIsNotNone(migration)
def test_first_admin_creation_is_only_allowed_once(self):
manager = self.create_manager([])
self.assertTrue(manager.create_first_admin("owner", hash_password("new-long-password")))
self.assertFalse(manager.create_first_admin("second", hash_password("second-long-password")))
self.assertEqual(manager.get_user("owner")["role"], "admin")
self.assertIsNone(manager.get_user("second"))
def test_password_change_clears_required_flag(self):
manager = self.create_manager([("owner", "legacy-password")])
self.assertTrue(manager.update_user_password("owner", hash_password("replacement-password")))
self.assertFalse(manager.get_user("owner")["must_change_password"])
def test_reset_requires_password_change_and_revokes_existing_tokens(self):
manager = self.create_manager([])
manager.create_first_admin("owner", hash_password("original-account-password"))
self.start_api(manager)
status, login = self.request_json("POST", "/api/login", {
"username": "owner",
"password": "original-account-password",
})
self.assertEqual(status, 200)
existing_token = login["token"]
user = manager.get_user("owner")
self.assertTrue(manager.reset_user_password(
"owner",
hash_password("temporary-account-password"),
))
reset_user = manager.get_user("owner")
self.assertEqual(reset_user["auth_version"], user["auth_version"] + 1)
self.assertTrue(reset_user["must_change_password"])
status, _ = self.request_json("GET", "/api/config", authorization=existing_token)
self.assertEqual(status, 401)
status, reset_login = self.request_json("POST", "/api/login", {
"username": "owner",
"password": "temporary-account-password",
})
self.assertEqual(status, 200)
self.assertTrue(reset_login["password_change_required"])
def test_password_reset_cli_updates_user_without_echoing_secret(self):
manager = self.create_manager([])
manager.create_first_admin("owner", hash_password("original-account-password"))
fake_input = io.StringIO()
fake_output = io.StringIO()
fake_input.isatty = lambda: True
fake_output.isatty = lambda: True
with (
patch.object(password_reset, "creds_manager", manager),
patch.object(password_reset.sys, "stdin", fake_input),
patch.object(password_reset.sys, "stdout", fake_output),
patch.object(password_reset.sys, "argv", ["password-reset", "owner"]),
patch.object(password_reset.getpass, "getpass", side_effect=[
"temporary-account-password",
"temporary-account-password",
]),
):
result = password_reset.main()
self.assertEqual(result, 0)
reset_user = manager.get_user("owner")
self.assertTrue(reset_user["must_change_password"])
self.assertTrue(verify_password("temporary-account-password", reset_user["password_hash"]))
self.assertNotIn("temporary-account-password", fake_output.getvalue())
def test_initial_setup_creates_only_one_admin(self):
manager = self.create_manager([])
self.start_api(manager)
status, data = self.request_json("GET", "/api/setup-status")
self.assertEqual(status, 200)
self.assertTrue(data["needs_setup"])
status, _ = self.request_json("POST", "/api/setup", {
"username": "first-admin",
"password": "initial-password-long",
})
self.assertEqual(status, 201)
self.assertEqual(manager.get_user("first-admin")["role"], "admin")
status, _ = self.request_json("POST", "/api/setup", {
"username": "second-admin",
"password": "another-password-long",
})
self.assertEqual(status, 409)
def test_admin_can_manage_users_and_regular_users_cannot(self):
manager = self.create_manager([])
manager.create_first_admin("owner", hash_password("owner-account-password"))
self.start_api(manager)
status, owner_login = self.request_json("POST", "/api/login", {
"username": "owner",
"password": "owner-account-password",
})
self.assertEqual(status, 200)
owner_token = owner_login["token"]
status, _ = self.request_json(
"POST",
"/api/users",
{
"username": "operator",
"password": "temporary-user-password",
"role": "user",
},
authorization=owner_token,
)
self.assertEqual(status, 201)
self.assertTrue(manager.get_user("operator")["must_change_password"])
status, listed = self.request_json("GET", "/api/users", authorization=owner_token)
self.assertEqual(status, 200)
self.assertEqual({user["username"] for user in listed["users"]}, {"owner", "operator"})
self.assertNotIn("password_hash", listed["users"][0])
status, _ = self.request_json(
"POST",
"/api/users/delete",
{"username": "owner"},
authorization=owner_token,
)
self.assertEqual(status, 409)
manager.update_user_password("operator", hash_password("operator-account-password"))
status, operator_login = self.request_json("POST", "/api/login", {
"username": "operator",
"password": "operator-account-password",
})
self.assertEqual(status, 200)
status, _ = self.request_json(
"GET", "/api/users", authorization=operator_login["token"]
)
self.assertEqual(status, 403)
status, _ = self.request_json(
"POST",
"/api/users/delete",
{"username": "operator"},
authorization=owner_token,
)
self.assertEqual(status, 200)
self.assertIsNone(manager.get_user("operator"))
def test_imported_user_is_limited_until_password_is_changed(self):
manager = self.create_manager([("owner", "legacy-owner-password")])
self.start_api(manager)
status, login_data = self.request_json("POST", "/api/login", {
"username": "owner",
"password": "legacy-owner-password",
})
self.assertEqual(status, 200)
self.assertTrue(login_data["password_change_required"])
pending_token = login_data["token"]
status, _ = self.request_json(
"GET", "/api/config", authorization=pending_token
)
self.assertEqual(status, 401)
status, changed = self.request_json(
"POST",
"/api/change-password",
{"password": "replacement-owner-password"},
authorization=pending_token,
)
self.assertEqual(status, 200)
status, _ = self.request_json("POST", "/api/verify", {"token": pending_token})
self.assertEqual(status, 401)
status, verified = self.request_json("POST", "/api/verify", {"token": changed["token"]})
self.assertEqual(status, 200)
self.assertTrue(verified["valid"])
self.assertEqual(verified["role"], "admin")
def start_api(self, manager):
self.server = HTTPServer(("127.0.0.1", 0), http_api.LoginHandler)
self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.server_thread.start()
self.manager_patch = patch.object(http_api, "creds_manager", manager)
self.manager_patch.start()
self.base_url = f"http://127.0.0.1:{self.server.server_port}"
def request_json(self, method, path, body=None, authorization=None):
headers = {"Content-Type": "application/json"}
if authorization:
headers["Authorization"] = f"Bearer {authorization}"
request = urllib.request.Request(
self.base_url + path,
data=json.dumps(body).encode() if body is not None else None,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(request) as response:
return response.status, json.loads(response.read())
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read())
def tearDown(self):
if hasattr(self, "manager_patch"):
self.manager_patch.stop()
self.server.shutdown()
self.server.server_close()
self.server_thread.join(timeout=2)
self.temp_dir.cleanup()
super().tearDown()
if __name__ == "__main__":
unittest.main()