webgate 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {webgate-0.2.0 → webgate-0.2.1}/CHANGELOG.md +15 -0
  2. {webgate-0.2.0 → webgate-0.2.1}/PKG-INFO +106 -1
  3. {webgate-0.2.0 → webgate-0.2.1}/README.md +103 -0
  4. webgate-0.2.1/docs/screenshots/access-control.png +0 -0
  5. webgate-0.2.1/docs/screenshots/audit.png +0 -0
  6. webgate-0.2.1/docs/screenshots/editor.png +0 -0
  7. webgate-0.2.1/docs/screenshots/light-theme.png +0 -0
  8. webgate-0.2.1/docs/screenshots/sftp.png +0 -0
  9. webgate-0.2.1/docs/screenshots/site-manager.png +0 -0
  10. webgate-0.2.1/docs/screenshots/split-view.png +0 -0
  11. webgate-0.2.1/docs/screenshots/terminal.png +0 -0
  12. webgate-0.2.1/docs/screenshots/users.png +0 -0
  13. {webgate-0.2.0 → webgate-0.2.1}/pyproject.toml +3 -1
  14. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/app.py +6 -1
  15. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/auth/models.py +59 -1
  16. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/auth/routes.py +153 -6
  17. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/auth/service.py +80 -1
  18. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/config.py +1 -0
  19. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/db/engine.py +2 -0
  20. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/static/index.html +207 -12
  21. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/terminal/ws_handler.py +11 -1
  22. {webgate-0.2.0 → webgate-0.2.1}/uv.lock +117 -0
  23. webgate-0.2.0/docs/screenshots/access-control.png +0 -0
  24. webgate-0.2.0/docs/screenshots/audit.png +0 -0
  25. webgate-0.2.0/docs/screenshots/editor.png +0 -0
  26. webgate-0.2.0/docs/screenshots/sftp.png +0 -0
  27. webgate-0.2.0/docs/screenshots/site-manager.png +0 -0
  28. webgate-0.2.0/docs/screenshots/split-view.png +0 -0
  29. webgate-0.2.0/docs/screenshots/terminal.png +0 -0
  30. webgate-0.2.0/docs/screenshots/users.png +0 -0
  31. {webgate-0.2.0 → webgate-0.2.1}/.dockerignore +0 -0
  32. {webgate-0.2.0 → webgate-0.2.1}/.github/workflows/docs.yml +0 -0
  33. {webgate-0.2.0 → webgate-0.2.1}/.gitignore +0 -0
  34. {webgate-0.2.0 → webgate-0.2.1}/Dockerfile +0 -0
  35. {webgate-0.2.0 → webgate-0.2.1}/Dockerfile.ssh-demo +0 -0
  36. {webgate-0.2.0 → webgate-0.2.1}/LICENSE +0 -0
  37. {webgate-0.2.0 → webgate-0.2.1}/ROADMAP.md +0 -0
  38. {webgate-0.2.0 → webgate-0.2.1}/VERSION +0 -0
  39. {webgate-0.2.0 → webgate-0.2.1}/compose.dev.yml +0 -0
  40. {webgate-0.2.0 → webgate-0.2.1}/compose.yml +0 -0
  41. {webgate-0.2.0 → webgate-0.2.1}/docs/api/auth.md +0 -0
  42. {webgate-0.2.0 → webgate-0.2.1}/docs/api/files.md +0 -0
  43. {webgate-0.2.0 → webgate-0.2.1}/docs/api/servers.md +0 -0
  44. {webgate-0.2.0 → webgate-0.2.1}/docs/api/terminal.md +0 -0
  45. {webgate-0.2.0 → webgate-0.2.1}/docs/changelog.md +0 -0
  46. {webgate-0.2.0 → webgate-0.2.1}/docs/getting-started/installation.md +0 -0
  47. {webgate-0.2.0 → webgate-0.2.1}/docs/getting-started/quickstart.md +0 -0
  48. {webgate-0.2.0 → webgate-0.2.1}/docs/guide/files.md +0 -0
  49. {webgate-0.2.0 → webgate-0.2.1}/docs/guide/servers.md +0 -0
  50. {webgate-0.2.0 → webgate-0.2.1}/docs/guide/split.md +0 -0
  51. {webgate-0.2.0 → webgate-0.2.1}/docs/guide/terminal.md +0 -0
  52. {webgate-0.2.0 → webgate-0.2.1}/docs/guide/users.md +0 -0
  53. {webgate-0.2.0 → webgate-0.2.1}/docs/index.md +0 -0
  54. {webgate-0.2.0 → webgate-0.2.1}/docs/screenshots/edit-access-control.png +0 -0
  55. {webgate-0.2.0 → webgate-0.2.1}/docs/screenshots/login.png +0 -0
  56. {webgate-0.2.0 → webgate-0.2.1}/docs/screenshots/new-server-form.png +0 -0
  57. {webgate-0.2.0 → webgate-0.2.1}/docs/screenshots/sftp-restricted.png +0 -0
  58. {webgate-0.2.0 → webgate-0.2.1}/docs/screenshots/ssh-disabled.png +0 -0
  59. {webgate-0.2.0 → webgate-0.2.1}/mkdocs.yml +0 -0
  60. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/__init__.py +0 -0
  61. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/__main__.py +0 -0
  62. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/audit/__init__.py +0 -0
  63. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/audit/models.py +0 -0
  64. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/audit/service.py +0 -0
  65. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/auth/__init__.py +0 -0
  66. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/db/__init__.py +0 -0
  67. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/files/__init__.py +0 -0
  68. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/files/models.py +0 -0
  69. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/files/pool.py +0 -0
  70. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/files/routes.py +0 -0
  71. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/files/sftp_service.py +0 -0
  72. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/__init__.py +0 -0
  73. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/crypto.py +0 -0
  74. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/models.py +0 -0
  75. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/monitor.py +0 -0
  76. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/routes.py +0 -0
  77. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/service.py +0 -0
  78. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/terminal/__init__.py +0 -0
  79. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/terminal/routes.py +0 -0
  80. {webgate-0.2.0 → webgate-0.2.1}/src/webgate/terminal/ssh_session.py +0 -0
  81. {webgate-0.2.0 → webgate-0.2.1}/tests/__init__.py +0 -0
  82. {webgate-0.2.0 → webgate-0.2.1}/tests/conftest.py +0 -0
  83. {webgate-0.2.0 → webgate-0.2.1}/tests/test_auth.py +0 -0
  84. {webgate-0.2.0 → webgate-0.2.1}/tests/test_files.py +0 -0
  85. {webgate-0.2.0 → webgate-0.2.1}/tests/test_servers.py +0 -0
  86. {webgate-0.2.0 → webgate-0.2.1}/tests/test_terminal.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.1 (2026-04-15)
4
+
5
+ ### Features
6
+
7
+ - **Reverse proxy sub-path support** -- webgate can now be served behind a reverse proxy at a URL prefix (e.g. `https://example.com/webgate/`). Previously the frontend used absolute `/api/...` paths that broke under any prefix.
8
+
9
+ ### Details
10
+
11
+ - New config setting: `WEBGATE_ROOT_PATH` (default `""`), passed to FastAPI's `root_path` for correct OpenAPI URLs behind proxies
12
+ - Frontend derives the path prefix at runtime from `window.location.pathname` and prepends it to all REST calls and the terminal WebSocket URL
13
+ - README documents nginx, Apache, and Traefik reverse-proxy configurations for sub-path deployments
14
+ - The proxy must forward the prefix unchanged (do not strip it) -- webgate handles the prefix natively
15
+
16
+ ---
17
+
3
18
  ## v0.2.0 (2026-04-09)
4
19
 
5
20
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webgate
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Self-hosted web application for remote server management via SSH terminal and SFTP file browser
5
5
  Project-URL: Homepage, https://github.com/kalexnolasco/webgate
6
6
  Project-URL: Documentation, https://kalexnolasco.github.io/webgate/
@@ -34,8 +34,10 @@ Requires-Dist: fastapi>=0.115.0
34
34
  Requires-Dist: passlib[bcrypt]>=1.7.4
35
35
  Requires-Dist: pydantic-settings>=2.7.0
36
36
  Requires-Dist: pydantic>=2.10.0
37
+ Requires-Dist: pyotp>=2.9.0
37
38
  Requires-Dist: python-jose[cryptography]>=3.3.0
38
39
  Requires-Dist: python-multipart>=0.0.18
40
+ Requires-Dist: qrcode[pil]>=8.2
39
41
  Requires-Dist: slowapi>=0.1.9
40
42
  Requires-Dist: sqlalchemy>=2.0.36
41
43
  Requires-Dist: uvicorn[standard]>=0.34.0
@@ -233,6 +235,9 @@ flowchart TB
233
235
  ### Audit Log
234
236
  ![Audit](docs/screenshots/audit.png)
235
237
 
238
+ ### Light Theme
239
+ ![Light Theme](docs/screenshots/light-theme.png)
240
+
236
241
  ## Quick Start
237
242
 
238
243
  ### Docker (recommended)
@@ -443,6 +448,7 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
443
448
  | `WEBGATE_SECRET_KEY` | `change-me-in-production` | JWT signing + Fernet encryption key |
444
449
  | `WEBGATE_DB_URL` | `sqlite+aiosqlite:///./webgate.db` | Database URL |
445
450
  | `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
451
+ | `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
446
452
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
447
453
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
448
454
  | `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
@@ -499,6 +505,105 @@ webgate.example.com {
499
505
  }
500
506
  ```
501
507
 
508
+ ### Reverse proxy at a sub-path (e.g. `https://example.com/webgate/`)
509
+
510
+ When webgate is exposed behind an existing site under a URL prefix, set `WEBGATE_ROOT_PATH` so FastAPI and the frontend both build URLs with the prefix. The frontend derives the prefix automatically from `window.location.pathname`, but `WEBGATE_ROOT_PATH` is still needed on the backend for OpenAPI URLs.
511
+
512
+ **Docker Compose:**
513
+
514
+ ```yaml
515
+ services:
516
+ webgate:
517
+ image: kalexnolasco/webgate:latest
518
+ environment:
519
+ WEBGATE_SECRET_KEY: "${WEBGATE_SECRET_KEY}"
520
+ WEBGATE_ROOT_PATH: "/webgate"
521
+ ```
522
+
523
+ > **Important:** the reverse proxy must **forward the prefix unchanged** (not strip it). Webgate receives requests as `/webgate/api/...` and handles them natively — do not rewrite them to `/api/...`.
524
+
525
+ #### nginx
526
+
527
+ ```nginx
528
+ server {
529
+ listen 443 ssl http2;
530
+ server_name example.com;
531
+
532
+ ssl_certificate /etc/ssl/certs/example.com.crt;
533
+ ssl_certificate_key /etc/ssl/private/example.com.key;
534
+
535
+ # WebSocket (xterm.js terminal) -- must come before the generic location
536
+ location /webgate/api/ws/ {
537
+ proxy_pass http://127.0.0.1:8443;
538
+ proxy_http_version 1.1;
539
+ proxy_set_header Upgrade $http_upgrade;
540
+ proxy_set_header Connection "upgrade";
541
+ proxy_set_header Host $host;
542
+ proxy_read_timeout 86400s; # keep long-lived SSH sessions alive
543
+ proxy_send_timeout 86400s;
544
+ }
545
+
546
+ location /webgate/ {
547
+ proxy_pass http://127.0.0.1:8443;
548
+ proxy_set_header Host $host;
549
+ proxy_set_header X-Real-IP $remote_addr;
550
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
551
+ proxy_set_header X-Forwarded-Proto https;
552
+ proxy_set_header X-Forwarded-Prefix /webgate;
553
+ client_max_body_size 100m; # match WEBGATE_MAX_UPLOAD_SIZE
554
+ }
555
+ }
556
+ ```
557
+
558
+ #### Apache 2.4
559
+
560
+ ```apache
561
+ # Required modules: proxy proxy_http proxy_wstunnel headers rewrite ssl
562
+ # sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite
563
+
564
+ <VirtualHost *:443>
565
+ ServerName example.com
566
+
567
+ SSLEngine on
568
+ SSLCertificateFile /etc/ssl/certs/example.com.crt
569
+ SSLCertificateKeyFile /etc/ssl/private/example.com.key
570
+
571
+ ProxyPreserveHost On
572
+ RequestHeader set X-Forwarded-Proto "https"
573
+ RequestHeader set X-Forwarded-Prefix "/webgate"
574
+
575
+ # Redirect /webgate -> /webgate/ (trailing slash)
576
+ RewriteEngine On
577
+ RewriteRule ^/webgate$ /webgate/ [R=301,L]
578
+
579
+ # WebSocket (xterm.js terminal) -- MUST come before the HTTP ProxyPass
580
+ ProxyPass /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
581
+ ProxyPassReverse /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
582
+
583
+ # HTTP
584
+ ProxyPass /webgate/ http://127.0.0.1:8443/webgate/
585
+ ProxyPassReverse /webgate/ http://127.0.0.1:8443/webgate/
586
+ </VirtualHost>
587
+ ```
588
+
589
+ #### Traefik (labels)
590
+
591
+ ```yaml
592
+ services:
593
+ webgate:
594
+ image: kalexnolasco/webgate:latest
595
+ environment:
596
+ WEBGATE_ROOT_PATH: "/webgate"
597
+ labels:
598
+ - "traefik.enable=true"
599
+ - "traefik.http.routers.webgate.rule=Host(`example.com`) && PathPrefix(`/webgate`)"
600
+ - "traefik.http.routers.webgate.entrypoints=websecure"
601
+ - "traefik.http.routers.webgate.tls=true"
602
+ - "traefik.http.services.webgate.loadbalancer.server.port=8443"
603
+ ```
604
+
605
+ Traefik forwards the full path by default, so no extra middleware is needed. Long WebSocket timeouts can be tuned via `forwardingTimeouts` in the Traefik static config.
606
+
502
607
  ### Useful Commands
503
608
 
504
609
  ```bash
@@ -189,6 +189,9 @@ flowchart TB
189
189
  ### Audit Log
190
190
  ![Audit](docs/screenshots/audit.png)
191
191
 
192
+ ### Light Theme
193
+ ![Light Theme](docs/screenshots/light-theme.png)
194
+
192
195
  ## Quick Start
193
196
 
194
197
  ### Docker (recommended)
@@ -399,6 +402,7 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
399
402
  | `WEBGATE_SECRET_KEY` | `change-me-in-production` | JWT signing + Fernet encryption key |
400
403
  | `WEBGATE_DB_URL` | `sqlite+aiosqlite:///./webgate.db` | Database URL |
401
404
  | `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
405
+ | `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
402
406
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
403
407
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
404
408
  | `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
@@ -455,6 +459,105 @@ webgate.example.com {
455
459
  }
456
460
  ```
457
461
 
462
+ ### Reverse proxy at a sub-path (e.g. `https://example.com/webgate/`)
463
+
464
+ When webgate is exposed behind an existing site under a URL prefix, set `WEBGATE_ROOT_PATH` so FastAPI and the frontend both build URLs with the prefix. The frontend derives the prefix automatically from `window.location.pathname`, but `WEBGATE_ROOT_PATH` is still needed on the backend for OpenAPI URLs.
465
+
466
+ **Docker Compose:**
467
+
468
+ ```yaml
469
+ services:
470
+ webgate:
471
+ image: kalexnolasco/webgate:latest
472
+ environment:
473
+ WEBGATE_SECRET_KEY: "${WEBGATE_SECRET_KEY}"
474
+ WEBGATE_ROOT_PATH: "/webgate"
475
+ ```
476
+
477
+ > **Important:** the reverse proxy must **forward the prefix unchanged** (not strip it). Webgate receives requests as `/webgate/api/...` and handles them natively — do not rewrite them to `/api/...`.
478
+
479
+ #### nginx
480
+
481
+ ```nginx
482
+ server {
483
+ listen 443 ssl http2;
484
+ server_name example.com;
485
+
486
+ ssl_certificate /etc/ssl/certs/example.com.crt;
487
+ ssl_certificate_key /etc/ssl/private/example.com.key;
488
+
489
+ # WebSocket (xterm.js terminal) -- must come before the generic location
490
+ location /webgate/api/ws/ {
491
+ proxy_pass http://127.0.0.1:8443;
492
+ proxy_http_version 1.1;
493
+ proxy_set_header Upgrade $http_upgrade;
494
+ proxy_set_header Connection "upgrade";
495
+ proxy_set_header Host $host;
496
+ proxy_read_timeout 86400s; # keep long-lived SSH sessions alive
497
+ proxy_send_timeout 86400s;
498
+ }
499
+
500
+ location /webgate/ {
501
+ proxy_pass http://127.0.0.1:8443;
502
+ proxy_set_header Host $host;
503
+ proxy_set_header X-Real-IP $remote_addr;
504
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
505
+ proxy_set_header X-Forwarded-Proto https;
506
+ proxy_set_header X-Forwarded-Prefix /webgate;
507
+ client_max_body_size 100m; # match WEBGATE_MAX_UPLOAD_SIZE
508
+ }
509
+ }
510
+ ```
511
+
512
+ #### Apache 2.4
513
+
514
+ ```apache
515
+ # Required modules: proxy proxy_http proxy_wstunnel headers rewrite ssl
516
+ # sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite
517
+
518
+ <VirtualHost *:443>
519
+ ServerName example.com
520
+
521
+ SSLEngine on
522
+ SSLCertificateFile /etc/ssl/certs/example.com.crt
523
+ SSLCertificateKeyFile /etc/ssl/private/example.com.key
524
+
525
+ ProxyPreserveHost On
526
+ RequestHeader set X-Forwarded-Proto "https"
527
+ RequestHeader set X-Forwarded-Prefix "/webgate"
528
+
529
+ # Redirect /webgate -> /webgate/ (trailing slash)
530
+ RewriteEngine On
531
+ RewriteRule ^/webgate$ /webgate/ [R=301,L]
532
+
533
+ # WebSocket (xterm.js terminal) -- MUST come before the HTTP ProxyPass
534
+ ProxyPass /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
535
+ ProxyPassReverse /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
536
+
537
+ # HTTP
538
+ ProxyPass /webgate/ http://127.0.0.1:8443/webgate/
539
+ ProxyPassReverse /webgate/ http://127.0.0.1:8443/webgate/
540
+ </VirtualHost>
541
+ ```
542
+
543
+ #### Traefik (labels)
544
+
545
+ ```yaml
546
+ services:
547
+ webgate:
548
+ image: kalexnolasco/webgate:latest
549
+ environment:
550
+ WEBGATE_ROOT_PATH: "/webgate"
551
+ labels:
552
+ - "traefik.enable=true"
553
+ - "traefik.http.routers.webgate.rule=Host(`example.com`) && PathPrefix(`/webgate`)"
554
+ - "traefik.http.routers.webgate.entrypoints=websecure"
555
+ - "traefik.http.routers.webgate.tls=true"
556
+ - "traefik.http.services.webgate.loadbalancer.server.port=8443"
557
+ ```
558
+
559
+ Traefik forwards the full path by default, so no extra middleware is needed. Long WebSocket timeouts can be tuned via `forwardingTimeouts` in the Traefik static config.
560
+
458
561
  ### Useful Commands
459
562
 
460
563
  ```bash
Binary file
Binary file
Binary file
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webgate"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Self-hosted web application for remote server management via SSH terminal and SFTP file browser"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -38,6 +38,8 @@ dependencies = [
38
38
  "cryptography>=44.0.0",
39
39
  "websockets>=14.0",
40
40
  "slowapi>=0.1.9",
41
+ "pyotp>=2.9.0",
42
+ "qrcode[pil]>=8.2",
41
43
  ]
42
44
 
43
45
  [project.urls]
@@ -33,7 +33,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
33
33
 
34
34
 
35
35
  def create_app() -> FastAPI:
36
- app = FastAPI(title="webgate", version="0.1.0", lifespan=lifespan)
36
+ app = FastAPI(
37
+ title="webgate",
38
+ version="0.1.0",
39
+ lifespan=lifespan,
40
+ root_path=settings.root_path,
41
+ )
37
42
  app.state.limiter = limiter
38
43
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
39
44
 
@@ -2,7 +2,7 @@ import json
2
2
  from datetime import datetime
3
3
 
4
4
  from pydantic import BaseModel, field_validator
5
- from sqlalchemy import Boolean, DateTime, String, Text, func
5
+ from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
6
6
  from sqlalchemy.orm import Mapped, mapped_column
7
7
 
8
8
  from webgate.db.engine import Base
@@ -17,6 +17,20 @@ class User(Base):
17
17
  is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
18
18
  must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
19
19
  allowed_groups: Mapped[str] = mapped_column(Text, default="[]") # JSON array of group names
20
+ totp_secret: Mapped[str] = mapped_column(String(255), default="")
21
+ totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
22
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
23
+
24
+
25
+ class ApiKey(Base):
26
+ __tablename__ = "api_keys"
27
+
28
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
29
+ user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
30
+ name: Mapped[str] = mapped_column(String(255))
31
+ key_hash: Mapped[str] = mapped_column(String(255))
32
+ key_prefix: Mapped[str] = mapped_column(String(10))
33
+ last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
20
34
  created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
21
35
 
22
36
 
@@ -28,6 +42,7 @@ class UserCreate(BaseModel):
28
42
  class UserLogin(BaseModel):
29
43
  username: str
30
44
  password: str
45
+ totp_code: str = ""
31
46
 
32
47
 
33
48
  class UserOut(BaseModel):
@@ -35,6 +50,7 @@ class UserOut(BaseModel):
35
50
  username: str
36
51
  is_admin: bool
37
52
  must_change_password: bool = False
53
+ totp_enabled: bool = False
38
54
  allowed_groups: list[str] = []
39
55
 
40
56
  model_config = {"from_attributes": True}
@@ -65,6 +81,48 @@ class ChangePassword(BaseModel):
65
81
  new_password: str
66
82
 
67
83
 
84
+ class TotpSetupOut(BaseModel):
85
+ secret: str
86
+ qr_uri: str
87
+ qr_base64: str
88
+
89
+
90
+ class TotpVerifyIn(BaseModel):
91
+ code: str
92
+
93
+
94
+ class TotpStatusOut(BaseModel):
95
+ enabled: bool
96
+
97
+
98
+ class LoginOut(BaseModel):
99
+ access_token: str = ""
100
+ token_type: str = "bearer"
101
+ requires_2fa: bool = False
102
+ temp_token: str = ""
103
+
104
+
68
105
  class TokenOut(BaseModel):
69
106
  access_token: str
70
107
  token_type: str = "bearer"
108
+
109
+
110
+ class ApiKeyCreate(BaseModel):
111
+ name: str
112
+
113
+
114
+ class ApiKeyOut(BaseModel):
115
+ id: int
116
+ name: str
117
+ key_prefix: str
118
+ last_used_at: datetime | None
119
+ created_at: datetime
120
+
121
+ model_config = {"from_attributes": True}
122
+
123
+
124
+ class ApiKeyCreated(BaseModel):
125
+ id: int
126
+ name: str
127
+ key: str
128
+ key_prefix: str
@@ -11,24 +11,37 @@ limiter = Limiter(key_func=get_remote_address)
11
11
  from webgate.audit.models import AuditOut
12
12
  from webgate.audit.service import get_audit_log, log_action
13
13
  from webgate.auth.models import (
14
+ ApiKeyCreate,
15
+ ApiKeyCreated,
16
+ ApiKeyOut,
14
17
  ChangePassword,
15
- TokenOut,
18
+ LoginOut,
19
+ TotpSetupOut,
20
+ TotpStatusOut,
21
+ TotpVerifyIn,
16
22
  UserLogin,
17
23
  UserManage,
18
24
  UserOut,
19
25
  UserUpdateGroups,
20
26
  )
21
27
  from webgate.auth.service import (
28
+ authenticate_api_key,
22
29
  create_access_token,
30
+ create_api_key,
23
31
  create_user,
24
32
  decode_access_token,
33
+ delete_api_key,
25
34
  delete_user,
35
+ generate_totp_secret,
36
+ get_api_keys,
37
+ get_totp_uri,
26
38
  get_user_by_id,
27
39
  get_user_by_username,
28
40
  list_users,
29
41
  update_user_groups,
30
42
  update_user_password,
31
43
  verify_password,
44
+ verify_totp,
32
45
  )
33
46
  from webgate.db.engine import get_session
34
47
 
@@ -40,7 +53,17 @@ AuthDep = Annotated[HTTPAuthorizationCredentials, Depends(security)]
40
53
 
41
54
 
42
55
  async def get_current_user(credentials: AuthDep, session: SessionDep) -> UserOut:
43
- payload = decode_access_token(credentials.credentials)
56
+ token = credentials.credentials
57
+
58
+ # Check if it's an API key (starts with "wg_")
59
+ if token.startswith("wg_"):
60
+ user = await authenticate_api_key(session, token)
61
+ if not user:
62
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
63
+ return UserOut.model_validate(user)
64
+
65
+ # Otherwise treat as JWT
66
+ payload = decode_access_token(token)
44
67
  if payload is None:
45
68
  raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
46
69
  user_id = payload.get("sub")
@@ -60,17 +83,35 @@ def _require_admin(user: UserOut) -> None:
60
83
  raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
61
84
 
62
85
 
63
- @router.post("/login", response_model=TokenOut)
86
+ @router.post("/login", response_model=LoginOut)
64
87
  @limiter.limit("10/minute")
65
- async def login(request: Request, body: UserLogin, session: SessionDep) -> TokenOut:
88
+ async def login(request: Request, body: UserLogin, session: SessionDep) -> LoginOut:
66
89
  user = await get_user_by_username(session, body.username)
67
90
  if not user or not verify_password(body.password, user.hashed_password):
68
91
  raise HTTPException(
69
92
  status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
70
93
  )
94
+ # Check if 2FA is enabled
95
+ if user.totp_enabled and user.totp_secret:
96
+ if not body.totp_code:
97
+ # Issue a short-lived temp token for 2FA verification
98
+ temp_token = create_access_token(
99
+ {"sub": str(user.id), "pending_2fa": True, "exp_minutes": 2}
100
+ )
101
+ return LoginOut(requires_2fa=True, temp_token=temp_token)
102
+ # Verify the TOTP code
103
+ if not verify_totp(user.totp_secret, body.totp_code):
104
+ raise HTTPException(
105
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code"
106
+ )
71
107
  token = create_access_token({"sub": str(user.id), "username": user.username})
72
- await log_action(user.id, user.username, "login", ip_address=request.client.host if request.client else "")
73
- return TokenOut(access_token=token)
108
+ await log_action(
109
+ user.id,
110
+ user.username,
111
+ "login",
112
+ ip_address=request.client.host if request.client else "",
113
+ )
114
+ return LoginOut(access_token=token)
74
115
 
75
116
 
76
117
  @router.get("/me", response_model=UserOut)
@@ -162,6 +203,112 @@ async def remove_user(
162
203
  await delete_user(session, user)
163
204
 
164
205
 
206
+ @router.post("/totp/setup", response_model=TotpSetupOut)
207
+ async def totp_setup(session: SessionDep, current_user: CurrentUserDep) -> TotpSetupOut:
208
+ import base64
209
+ import io
210
+
211
+ import qrcode
212
+
213
+ user = await get_user_by_id(session, current_user.id)
214
+ if not user:
215
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
216
+ secret = generate_totp_secret()
217
+ user.totp_secret = secret
218
+ await session.commit()
219
+ uri = get_totp_uri(secret, user.username)
220
+ qr = qrcode.make(uri)
221
+ buf = io.BytesIO()
222
+ qr.save(buf, format="PNG")
223
+ qr_b64 = base64.b64encode(buf.getvalue()).decode()
224
+ return TotpSetupOut(secret=secret, qr_uri=uri, qr_base64=f"data:image/png;base64,{qr_b64}")
225
+
226
+
227
+ @router.post("/totp/verify", response_model=TotpStatusOut)
228
+ async def totp_verify(
229
+ body: TotpVerifyIn, session: SessionDep, current_user: CurrentUserDep
230
+ ) -> TotpStatusOut:
231
+ user = await get_user_by_id(session, current_user.id)
232
+ if not user:
233
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
234
+ if not user.totp_secret:
235
+ raise HTTPException(
236
+ status_code=status.HTTP_400_BAD_REQUEST, detail="Run TOTP setup first"
237
+ )
238
+ if not verify_totp(user.totp_secret, body.code):
239
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP code")
240
+ user.totp_enabled = True
241
+ await session.commit()
242
+ return TotpStatusOut(enabled=True)
243
+
244
+
245
+ @router.post("/totp/disable", response_model=TotpStatusOut)
246
+ async def totp_disable(
247
+ body: TotpVerifyIn, session: SessionDep, current_user: CurrentUserDep
248
+ ) -> TotpStatusOut:
249
+ user = await get_user_by_id(session, current_user.id)
250
+ if not user:
251
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
252
+ if not user.totp_enabled:
253
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled")
254
+ if not verify_totp(user.totp_secret, body.code):
255
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP code")
256
+ user.totp_secret = ""
257
+ user.totp_enabled = False
258
+ await session.commit()
259
+ return TotpStatusOut(enabled=False)
260
+
261
+
262
+ @router.put("/users/{user_id}/totp-reset", response_model=UserOut)
263
+ async def reset_user_totp(
264
+ user_id: int, session: SessionDep, current_user: CurrentUserDep
265
+ ) -> UserOut:
266
+ _require_admin(current_user)
267
+ user = await get_user_by_id(session, user_id)
268
+ if not user:
269
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
270
+ user.totp_secret = ""
271
+ user.totp_enabled = False
272
+ await session.commit()
273
+ await session.refresh(user)
274
+ return UserOut.model_validate(user)
275
+
276
+
277
+ # ---- API Keys ----
278
+
279
+
280
+ @router.get("/api-keys", response_model=list[ApiKeyOut])
281
+ async def list_api_keys(session: SessionDep, current_user: CurrentUserDep) -> list[ApiKeyOut]:
282
+ keys = await get_api_keys(session, current_user.id)
283
+ return [ApiKeyOut.model_validate(k) for k in keys]
284
+
285
+
286
+ @router.post("/api-keys", response_model=ApiKeyCreated, status_code=status.HTTP_201_CREATED)
287
+ async def create_api_key_endpoint(
288
+ body: ApiKeyCreate, session: SessionDep, current_user: CurrentUserDep
289
+ ) -> ApiKeyCreated:
290
+ if not body.name.strip():
291
+ raise HTTPException(
292
+ status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required"
293
+ )
294
+ key_obj, plaintext_key = await create_api_key(session, current_user.id, body.name.strip())
295
+ return ApiKeyCreated(
296
+ id=key_obj.id,
297
+ name=key_obj.name,
298
+ key=plaintext_key,
299
+ key_prefix=key_obj.key_prefix,
300
+ )
301
+
302
+
303
+ @router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
304
+ async def revoke_api_key(
305
+ key_id: int, session: SessionDep, current_user: CurrentUserDep
306
+ ) -> None:
307
+ deleted = await delete_api_key(session, key_id, current_user.id)
308
+ if not deleted:
309
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
310
+
311
+
165
312
  @router.get("/audit", response_model=list[AuditOut])
166
313
  async def audit_log_endpoint(
167
314
  session: SessionDep,