webgate 0.1.0__py3-none-any.whl
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.
- webgate/__init__.py +1 -0
- webgate/__main__.py +18 -0
- webgate/app.py +58 -0
- webgate/audit/__init__.py +0 -0
- webgate/audit/models.py +31 -0
- webgate/audit/service.py +44 -0
- webgate/auth/__init__.py +0 -0
- webgate/auth/models.py +70 -0
- webgate/auth/routes.py +175 -0
- webgate/auth/service.py +115 -0
- webgate/config.py +28 -0
- webgate/db/__init__.py +0 -0
- webgate/db/engine.py +36 -0
- webgate/files/__init__.py +0 -0
- webgate/files/models.py +36 -0
- webgate/files/pool.py +139 -0
- webgate/files/routes.py +192 -0
- webgate/files/sftp_service.py +186 -0
- webgate/servers/__init__.py +0 -0
- webgate/servers/crypto.py +27 -0
- webgate/servers/models.py +72 -0
- webgate/servers/routes.py +129 -0
- webgate/servers/service.py +201 -0
- webgate/static/index.html +1090 -0
- webgate/terminal/__init__.py +0 -0
- webgate/terminal/routes.py +110 -0
- webgate/terminal/ssh_session.py +83 -0
- webgate/terminal/ws_handler.py +90 -0
- webgate-0.1.0.dist-info/METADATA +557 -0
- webgate-0.1.0.dist-info/RECORD +33 -0
- webgate-0.1.0.dist-info/WHEEL +4 -0
- webgate-0.1.0.dist-info/entry_points.txt +2 -0
- webgate-0.1.0.dist-info/licenses/LICENSE +21 -0
webgate/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""webgate — self-hosted SSH terminal and SFTP file browser."""
|
webgate/__main__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import uvicorn
|
|
2
|
+
|
|
3
|
+
from webgate.config import settings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
uvicorn.run(
|
|
8
|
+
"webgate.app:create_app",
|
|
9
|
+
factory=True,
|
|
10
|
+
host=settings.host,
|
|
11
|
+
port=settings.port,
|
|
12
|
+
log_level=settings.log_level,
|
|
13
|
+
reload=False,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|
webgate/app.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
|
|
8
|
+
from slowapi import _rate_limit_exceeded_handler
|
|
9
|
+
from slowapi.errors import RateLimitExceeded
|
|
10
|
+
|
|
11
|
+
from webgate.auth.routes import limiter
|
|
12
|
+
from webgate.auth.routes import router as auth_router
|
|
13
|
+
from webgate.auth.service import seed_admin
|
|
14
|
+
from webgate.config import settings
|
|
15
|
+
from webgate.db.engine import async_session_factory, close_db, init_db
|
|
16
|
+
from webgate.files.pool import sftp_pool
|
|
17
|
+
from webgate.files.routes import router as files_router
|
|
18
|
+
from webgate.servers.routes import router as servers_router
|
|
19
|
+
from webgate.terminal.routes import router as terminal_router
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@asynccontextmanager
|
|
23
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
|
24
|
+
await init_db()
|
|
25
|
+
async with async_session_factory() as session:
|
|
26
|
+
await seed_admin(session)
|
|
27
|
+
await sftp_pool.start()
|
|
28
|
+
yield
|
|
29
|
+
await sftp_pool.stop()
|
|
30
|
+
await close_db()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_app() -> FastAPI:
|
|
34
|
+
app = FastAPI(title="webgate", version="0.1.0", lifespan=lifespan)
|
|
35
|
+
app.state.limiter = limiter
|
|
36
|
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
37
|
+
|
|
38
|
+
origins = [o.strip() for o in settings.allowed_origins.split(",")]
|
|
39
|
+
app.add_middleware(
|
|
40
|
+
CORSMiddleware,
|
|
41
|
+
allow_origins=origins,
|
|
42
|
+
allow_credentials=True,
|
|
43
|
+
allow_methods=["*"],
|
|
44
|
+
allow_headers=["*"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@app.get("/api/health")
|
|
48
|
+
async def health() -> dict[str, str]: # pyright: ignore[reportUnusedFunction]
|
|
49
|
+
return {"status": "ok"}
|
|
50
|
+
|
|
51
|
+
app.include_router(auth_router)
|
|
52
|
+
app.include_router(servers_router)
|
|
53
|
+
app.include_router(terminal_router)
|
|
54
|
+
app.include_router(files_router)
|
|
55
|
+
|
|
56
|
+
app.mount("/", StaticFiles(directory=str(settings.static_dir), html=True), name="static")
|
|
57
|
+
|
|
58
|
+
return app
|
|
File without changes
|
webgate/audit/models.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from sqlalchemy import DateTime, Integer, String, Text, func
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
|
|
7
|
+
from webgate.db.engine import Base
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuditEntry(Base):
|
|
11
|
+
__tablename__ = "audit_log"
|
|
12
|
+
|
|
13
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
14
|
+
user_id: Mapped[int] = mapped_column(Integer)
|
|
15
|
+
username: Mapped[str] = mapped_column(String(150))
|
|
16
|
+
action: Mapped[str] = mapped_column(String(50)) # login, ssh_connect, sftp_ls, server_create, etc.
|
|
17
|
+
detail: Mapped[str] = mapped_column(Text, default="")
|
|
18
|
+
ip_address: Mapped[str] = mapped_column(String(45), default="")
|
|
19
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuditOut(BaseModel):
|
|
23
|
+
id: int
|
|
24
|
+
user_id: int
|
|
25
|
+
username: str
|
|
26
|
+
action: str
|
|
27
|
+
detail: str
|
|
28
|
+
ip_address: str
|
|
29
|
+
created_at: datetime
|
|
30
|
+
|
|
31
|
+
model_config = {"from_attributes": True}
|
webgate/audit/service.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from webgate.audit.models import AuditEntry, AuditOut
|
|
7
|
+
from webgate.db.engine import async_session_factory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def log_action(
|
|
11
|
+
user_id: int,
|
|
12
|
+
username: str,
|
|
13
|
+
action: str,
|
|
14
|
+
detail: str = "",
|
|
15
|
+
ip_address: str = "",
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Fire-and-forget audit log entry."""
|
|
18
|
+
async with async_session_factory() as session:
|
|
19
|
+
entry = AuditEntry(
|
|
20
|
+
user_id=user_id,
|
|
21
|
+
username=username,
|
|
22
|
+
action=action,
|
|
23
|
+
detail=detail,
|
|
24
|
+
ip_address=ip_address,
|
|
25
|
+
)
|
|
26
|
+
session.add(entry)
|
|
27
|
+
await session.commit()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def get_audit_log(
|
|
31
|
+
session: AsyncSession,
|
|
32
|
+
limit: int = 100,
|
|
33
|
+
offset: int = 0,
|
|
34
|
+
username: str | None = None,
|
|
35
|
+
action: str | None = None,
|
|
36
|
+
) -> list[AuditOut]:
|
|
37
|
+
stmt = select(AuditEntry).order_by(AuditEntry.created_at.desc())
|
|
38
|
+
if username:
|
|
39
|
+
stmt = stmt.where(AuditEntry.username == username)
|
|
40
|
+
if action:
|
|
41
|
+
stmt = stmt.where(AuditEntry.action == action)
|
|
42
|
+
stmt = stmt.offset(offset).limit(limit)
|
|
43
|
+
result = await session.execute(stmt)
|
|
44
|
+
return [AuditOut.model_validate(e) for e in result.scalars().all()]
|
webgate/auth/__init__.py
ADDED
|
File without changes
|
webgate/auth/models.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, field_validator
|
|
5
|
+
from sqlalchemy import Boolean, DateTime, String, Text, func
|
|
6
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
7
|
+
|
|
8
|
+
from webgate.db.engine import Base
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class User(Base):
|
|
12
|
+
__tablename__ = "users"
|
|
13
|
+
|
|
14
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
15
|
+
username: Mapped[str] = mapped_column(String(150), unique=True, index=True)
|
|
16
|
+
hashed_password: Mapped[str] = mapped_column(String(255))
|
|
17
|
+
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
18
|
+
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
19
|
+
allowed_groups: Mapped[str] = mapped_column(Text, default="[]") # JSON array of group names
|
|
20
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UserCreate(BaseModel):
|
|
24
|
+
username: str
|
|
25
|
+
password: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UserLogin(BaseModel):
|
|
29
|
+
username: str
|
|
30
|
+
password: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UserOut(BaseModel):
|
|
34
|
+
id: int
|
|
35
|
+
username: str
|
|
36
|
+
is_admin: bool
|
|
37
|
+
must_change_password: bool = False
|
|
38
|
+
allowed_groups: list[str] = []
|
|
39
|
+
|
|
40
|
+
model_config = {"from_attributes": True}
|
|
41
|
+
|
|
42
|
+
@field_validator("allowed_groups", mode="before")
|
|
43
|
+
@classmethod
|
|
44
|
+
def parse_groups(cls, v: object) -> list[str]:
|
|
45
|
+
if isinstance(v, str):
|
|
46
|
+
try:
|
|
47
|
+
parsed = json.loads(v)
|
|
48
|
+
return parsed if isinstance(parsed, list) else []
|
|
49
|
+
except (json.JSONDecodeError, TypeError):
|
|
50
|
+
return []
|
|
51
|
+
return v if isinstance(v, list) else []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UserManage(BaseModel):
|
|
55
|
+
username: str
|
|
56
|
+
password: str = ""
|
|
57
|
+
allowed_groups: list[str] = []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class UserUpdateGroups(BaseModel):
|
|
61
|
+
allowed_groups: list[str]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ChangePassword(BaseModel):
|
|
65
|
+
new_password: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TokenOut(BaseModel):
|
|
69
|
+
access_token: str
|
|
70
|
+
token_type: str = "bearer"
|
webgate/auth/routes.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
4
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
5
|
+
from slowapi import Limiter
|
|
6
|
+
from slowapi.util import get_remote_address
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
10
|
+
|
|
11
|
+
from webgate.audit.models import AuditOut
|
|
12
|
+
from webgate.audit.service import get_audit_log, log_action
|
|
13
|
+
from webgate.auth.models import (
|
|
14
|
+
ChangePassword,
|
|
15
|
+
TokenOut,
|
|
16
|
+
UserLogin,
|
|
17
|
+
UserManage,
|
|
18
|
+
UserOut,
|
|
19
|
+
UserUpdateGroups,
|
|
20
|
+
)
|
|
21
|
+
from webgate.auth.service import (
|
|
22
|
+
create_access_token,
|
|
23
|
+
create_user,
|
|
24
|
+
decode_access_token,
|
|
25
|
+
delete_user,
|
|
26
|
+
get_user_by_id,
|
|
27
|
+
get_user_by_username,
|
|
28
|
+
list_users,
|
|
29
|
+
update_user_groups,
|
|
30
|
+
update_user_password,
|
|
31
|
+
verify_password,
|
|
32
|
+
)
|
|
33
|
+
from webgate.db.engine import get_session
|
|
34
|
+
|
|
35
|
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
36
|
+
security = HTTPBearer()
|
|
37
|
+
|
|
38
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
39
|
+
AuthDep = Annotated[HTTPAuthorizationCredentials, Depends(security)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def get_current_user(credentials: AuthDep, session: SessionDep) -> UserOut:
|
|
43
|
+
payload = decode_access_token(credentials.credentials)
|
|
44
|
+
if payload is None:
|
|
45
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
46
|
+
user_id = payload.get("sub")
|
|
47
|
+
if user_id is None:
|
|
48
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
49
|
+
user = await get_user_by_id(session, int(user_id))
|
|
50
|
+
if user is None:
|
|
51
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
|
52
|
+
return UserOut.model_validate(user)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
CurrentUserDep = Annotated[UserOut, Depends(get_current_user)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _require_admin(user: UserOut) -> None:
|
|
59
|
+
if not user.is_admin:
|
|
60
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.post("/login", response_model=TokenOut)
|
|
64
|
+
@limiter.limit("10/minute")
|
|
65
|
+
async def login(request: Request, body: UserLogin, session: SessionDep) -> TokenOut:
|
|
66
|
+
user = await get_user_by_username(session, body.username)
|
|
67
|
+
if not user or not verify_password(body.password, user.hashed_password):
|
|
68
|
+
raise HTTPException(
|
|
69
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
|
70
|
+
)
|
|
71
|
+
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)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.get("/me", response_model=UserOut)
|
|
77
|
+
async def me(current_user: CurrentUserDep) -> UserOut:
|
|
78
|
+
return current_user
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@router.post("/change-password", response_model=UserOut)
|
|
82
|
+
@limiter.limit("5/minute")
|
|
83
|
+
async def change_password(
|
|
84
|
+
request: Request, body: ChangePassword, session: SessionDep, current_user: CurrentUserDep
|
|
85
|
+
) -> UserOut:
|
|
86
|
+
if len(body.new_password) < 4:
|
|
87
|
+
raise HTTPException(
|
|
88
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Password too short (min 4 chars)"
|
|
89
|
+
)
|
|
90
|
+
user = await get_user_by_id(session, current_user.id)
|
|
91
|
+
if not user:
|
|
92
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
93
|
+
updated = await update_user_password(session, user, body.new_password)
|
|
94
|
+
updated.must_change_password = False
|
|
95
|
+
await session.commit()
|
|
96
|
+
await session.refresh(updated)
|
|
97
|
+
return UserOut.model_validate(updated)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---- Admin: user management ----
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.get("/users", response_model=list[UserOut])
|
|
104
|
+
async def get_users(session: SessionDep, current_user: CurrentUserDep) -> list[UserOut]:
|
|
105
|
+
_require_admin(current_user)
|
|
106
|
+
users = await list_users(session)
|
|
107
|
+
return [UserOut.model_validate(u) for u in users]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
|
111
|
+
async def create_new_user(
|
|
112
|
+
body: UserManage, session: SessionDep, current_user: CurrentUserDep
|
|
113
|
+
) -> UserOut:
|
|
114
|
+
_require_admin(current_user)
|
|
115
|
+
existing = await get_user_by_username(session, body.username)
|
|
116
|
+
if existing:
|
|
117
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken")
|
|
118
|
+
if not body.password:
|
|
119
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password required")
|
|
120
|
+
user = await create_user(
|
|
121
|
+
session, body.username, body.password, allowed_groups=body.allowed_groups
|
|
122
|
+
)
|
|
123
|
+
return UserOut.model_validate(user)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@router.put("/users/{user_id}/groups", response_model=UserOut)
|
|
127
|
+
async def set_user_groups(
|
|
128
|
+
user_id: int, body: UserUpdateGroups, session: SessionDep, current_user: CurrentUserDep
|
|
129
|
+
) -> UserOut:
|
|
130
|
+
_require_admin(current_user)
|
|
131
|
+
user = await get_user_by_id(session, user_id)
|
|
132
|
+
if not user:
|
|
133
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
134
|
+
if user.is_admin:
|
|
135
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify admin")
|
|
136
|
+
updated = await update_user_groups(session, user, body.allowed_groups)
|
|
137
|
+
return UserOut.model_validate(updated)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.put("/users/{user_id}/password", response_model=UserOut)
|
|
141
|
+
async def reset_user_password(
|
|
142
|
+
user_id: int, body: UserLogin, session: SessionDep, current_user: CurrentUserDep
|
|
143
|
+
) -> UserOut:
|
|
144
|
+
_require_admin(current_user)
|
|
145
|
+
user = await get_user_by_id(session, user_id)
|
|
146
|
+
if not user:
|
|
147
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
148
|
+
updated = await update_user_password(session, user, body.password)
|
|
149
|
+
return UserOut.model_validate(updated)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
153
|
+
async def remove_user(
|
|
154
|
+
user_id: int, session: SessionDep, current_user: CurrentUserDep
|
|
155
|
+
) -> None:
|
|
156
|
+
_require_admin(current_user)
|
|
157
|
+
user = await get_user_by_id(session, user_id)
|
|
158
|
+
if not user:
|
|
159
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
160
|
+
if user.is_admin:
|
|
161
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete admin")
|
|
162
|
+
await delete_user(session, user)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@router.get("/audit", response_model=list[AuditOut])
|
|
166
|
+
async def audit_log_endpoint(
|
|
167
|
+
session: SessionDep,
|
|
168
|
+
current_user: CurrentUserDep,
|
|
169
|
+
limit: int = 100,
|
|
170
|
+
offset: int = 0,
|
|
171
|
+
username: str | None = None,
|
|
172
|
+
action: str | None = None,
|
|
173
|
+
) -> list[AuditOut]:
|
|
174
|
+
_require_admin(current_user)
|
|
175
|
+
return await get_audit_log(session, limit=limit, offset=offset, username=username, action=action)
|
webgate/auth/service.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import bcrypt
|
|
9
|
+
from jose import JWTError, jwt
|
|
10
|
+
from sqlalchemy import func, select
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
from webgate.auth.models import User
|
|
14
|
+
from webgate.config import settings
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def hash_password(password: str) -> str:
|
|
20
|
+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
24
|
+
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_access_token(data: dict[str, Any]) -> str:
|
|
28
|
+
to_encode = data.copy()
|
|
29
|
+
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_expire_minutes)
|
|
30
|
+
to_encode["exp"] = expire
|
|
31
|
+
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decode_access_token(token: str) -> dict[str, Any] | None:
|
|
35
|
+
try:
|
|
36
|
+
payload: dict[str, Any] = jwt.decode(
|
|
37
|
+
token, settings.secret_key, algorithms=[settings.jwt_algorithm]
|
|
38
|
+
)
|
|
39
|
+
return payload
|
|
40
|
+
except JWTError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def get_user_by_username(session: AsyncSession, username: str) -> User | None:
|
|
45
|
+
result = await session.execute(select(User).where(User.username == username))
|
|
46
|
+
return result.scalar_one_or_none()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def get_user_by_id(session: AsyncSession, user_id: int) -> User | None:
|
|
50
|
+
result = await session.execute(select(User).where(User.id == user_id))
|
|
51
|
+
return result.scalar_one_or_none()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def get_user_count(session: AsyncSession) -> int:
|
|
55
|
+
result = await session.execute(select(func.count()).select_from(User))
|
|
56
|
+
count = result.scalar_one()
|
|
57
|
+
return int(count)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def create_user(
|
|
61
|
+
session: AsyncSession,
|
|
62
|
+
username: str,
|
|
63
|
+
password: str,
|
|
64
|
+
is_admin: bool = False,
|
|
65
|
+
allowed_groups: list[str] | None = None,
|
|
66
|
+
) -> User:
|
|
67
|
+
user = User(
|
|
68
|
+
username=username,
|
|
69
|
+
hashed_password=hash_password(password),
|
|
70
|
+
is_admin=is_admin,
|
|
71
|
+
allowed_groups=json.dumps(allowed_groups or []),
|
|
72
|
+
)
|
|
73
|
+
session.add(user)
|
|
74
|
+
await session.commit()
|
|
75
|
+
await session.refresh(user)
|
|
76
|
+
return user
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def seed_admin(session: AsyncSession) -> None:
|
|
80
|
+
"""Create default admin user if no users exist."""
|
|
81
|
+
count = await get_user_count(session)
|
|
82
|
+
if count > 0:
|
|
83
|
+
return
|
|
84
|
+
user = await create_user(session, "admin", "admin", is_admin=True)
|
|
85
|
+
user.must_change_password = True
|
|
86
|
+
await session.commit()
|
|
87
|
+
logger.info("Created default admin user (admin/admin) — password change required on first login")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def list_users(session: AsyncSession) -> list[User]:
|
|
91
|
+
result = await session.execute(select(User).order_by(User.username))
|
|
92
|
+
return list(result.scalars().all())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def update_user_groups(
|
|
96
|
+
session: AsyncSession, user: User, groups: list[str]
|
|
97
|
+
) -> User:
|
|
98
|
+
user.allowed_groups = json.dumps(groups)
|
|
99
|
+
await session.commit()
|
|
100
|
+
await session.refresh(user)
|
|
101
|
+
return user
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def update_user_password(
|
|
105
|
+
session: AsyncSession, user: User, new_password: str
|
|
106
|
+
) -> User:
|
|
107
|
+
user.hashed_password = hash_password(new_password)
|
|
108
|
+
await session.commit()
|
|
109
|
+
await session.refresh(user)
|
|
110
|
+
return user
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def delete_user(session: AsyncSession, user: User) -> None:
|
|
114
|
+
await session.delete(user)
|
|
115
|
+
await session.commit()
|
webgate/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Settings(BaseSettings):
|
|
7
|
+
model_config = {"env_prefix": "WEBGATE_"}
|
|
8
|
+
|
|
9
|
+
host: str = "0.0.0.0"
|
|
10
|
+
port: int = 8443
|
|
11
|
+
secret_key: str = "change-me-in-production"
|
|
12
|
+
db_url: str = "sqlite+aiosqlite:///./webgate.db"
|
|
13
|
+
allowed_origins: str = "*"
|
|
14
|
+
log_level: str = "info"
|
|
15
|
+
session_timeout: int = 3600
|
|
16
|
+
max_upload_size: int = 104857600 # 100MB
|
|
17
|
+
first_run: bool = True
|
|
18
|
+
|
|
19
|
+
# JWT settings
|
|
20
|
+
jwt_algorithm: str = "HS256"
|
|
21
|
+
jwt_expire_minutes: int = 1440 # 24 hours
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def static_dir(self) -> Path:
|
|
25
|
+
return Path(__file__).parent / "static"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
settings = Settings()
|
webgate/db/__init__.py
ADDED
|
File without changes
|
webgate/db/engine.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import MetaData
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
5
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
6
|
+
|
|
7
|
+
from webgate.config import settings
|
|
8
|
+
|
|
9
|
+
engine = create_async_engine(settings.db_url, echo=False)
|
|
10
|
+
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
11
|
+
|
|
12
|
+
convention = {
|
|
13
|
+
"ix": "ix_%(column_0_label)s",
|
|
14
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
15
|
+
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
16
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
17
|
+
"pk": "pk_%(table_name)s",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Base(DeclarativeBase):
|
|
22
|
+
metadata = MetaData(naming_convention=convention)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def get_session() -> AsyncGenerator[AsyncSession]:
|
|
26
|
+
async with async_session_factory() as session:
|
|
27
|
+
yield session
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def init_db() -> None:
|
|
31
|
+
async with engine.begin() as conn:
|
|
32
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def close_db() -> None:
|
|
36
|
+
await engine.dispose()
|
|
File without changes
|
webgate/files/models.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FileEntry(BaseModel):
|
|
5
|
+
name: str
|
|
6
|
+
path: str
|
|
7
|
+
is_dir: bool
|
|
8
|
+
size: int
|
|
9
|
+
permissions: str
|
|
10
|
+
owner: str
|
|
11
|
+
group: str
|
|
12
|
+
modified: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DirectoryListing(BaseModel):
|
|
16
|
+
path: str
|
|
17
|
+
entries: list[FileEntry]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FileWriteRequest(BaseModel):
|
|
21
|
+
path: str
|
|
22
|
+
content: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MkdirRequest(BaseModel):
|
|
26
|
+
path: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RenameRequest(BaseModel):
|
|
30
|
+
old_path: str
|
|
31
|
+
new_path: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ChmodRequest(BaseModel):
|
|
35
|
+
path: str
|
|
36
|
+
mode: str
|