pygitweb 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.
- pygitweb/__init__.py +22 -0
- pygitweb/__meta__.py +9 -0
- pygitweb/actions.py +1303 -0
- pygitweb/addproject_permissions_test.py +114 -0
- pygitweb/api/__init__.py +0 -0
- pygitweb/api/action.py +20 -0
- pygitweb/api/plugins/__init__.py +0 -0
- pygitweb/api/plugins/project_zip.py +71 -0
- pygitweb/api/project.py +67 -0
- pygitweb/api/subpage.py +22 -0
- pygitweb/auth.py +387 -0
- pygitweb/auth.schema.json +106 -0
- pygitweb/auth_config.py +243 -0
- pygitweb/auth_oauth.py +343 -0
- pygitweb/auth_test.py +390 -0
- pygitweb/boards_permissions_test.py +113 -0
- pygitweb/change_queue.py +110 -0
- pygitweb/change_queue_routes_test.py +146 -0
- pygitweb/change_queue_test.py +91 -0
- pygitweb/comments_permissions_test.py +140 -0
- pygitweb/config.py +185 -0
- pygitweb/conftest.py +43 -0
- pygitweb/dependencies.py +87 -0
- pygitweb/formatting.py +182 -0
- pygitweb/formatting_test.py +68 -0
- pygitweb/git_helpers.py +454 -0
- pygitweb/git_helpers_test.py +223 -0
- pygitweb/gravatar.py +16 -0
- pygitweb/gravatar_test.py +10 -0
- pygitweb/hooks_install.py +278 -0
- pygitweb/hooks_install_test.py +363 -0
- pygitweb/hooks_permissions_test.py +116 -0
- pygitweb/main.py +887 -0
- pygitweb/merge_requests.py +230 -0
- pygitweb/merge_requests_test.py +85 -0
- pygitweb/mr_permissions_test.py +148 -0
- pygitweb/password_hash.py +74 -0
- pygitweb/password_hash_test.py +20 -0
- pygitweb/permissions.py +209 -0
- pygitweb/permissions_test.py +86 -0
- pygitweb/plugin_loader.py +83 -0
- pygitweb/projects.py +213 -0
- pygitweb/projects_test.py +186 -0
- pygitweb/sessions.py +73 -0
- pygitweb/settings.py +412 -0
- pygitweb/settings.schema.json +73 -0
- pygitweb/settings_config.py +122 -0
- pygitweb/settings_config_test.py +101 -0
- pygitweb/settings_permissions_test.py +76 -0
- pygitweb/static/blob-view.js +133 -0
- pygitweb/static/board.js +839 -0
- pygitweb/static/hook-install.js +53 -0
- pygitweb/static/icons/cloud-down.svg +1 -0
- pygitweb/static/icons/cloud-exclamation.svg +1 -0
- pygitweb/static/icons/cloud-filled.svg +1 -0
- pygitweb/static/icons/cloud-storm.svg +1 -0
- pygitweb/static/icons/cloud-up.svg +1 -0
- pygitweb/static/icons/cloud.svg +1 -0
- pygitweb/static/logo.svg +44 -0
- pygitweb/static/main.css +1210 -0
- pygitweb/static/navbar-auth.js +82 -0
- pygitweb/static/readme-render.js +174 -0
- pygitweb/static/search-page.js +139 -0
- pygitweb/static/summary-ref-switcher.js +130 -0
- pygitweb/static/themes/solarized.css +34 -0
- pygitweb/tasks.py +595 -0
- pygitweb/tasks_permissions_test.py +137 -0
- pygitweb/tasks_routes_test.py +430 -0
- pygitweb/templates/addproject.html +174 -0
- pygitweb/templates/blobdiff.html +57 -0
- pygitweb/templates/board.html +31 -0
- pygitweb/templates/board_card.html +44 -0
- pygitweb/templates/commitdiff.html +57 -0
- pygitweb/templates/hooks_summary_cell.html +37 -0
- pygitweb/templates/login.html +37 -0
- pygitweb/templates/merge_request_summary_cell.html +18 -0
- pygitweb/templates/merge_request_tag.html +90 -0
- pygitweb/templates/pagination.html +44 -0
- pygitweb/templates/preamble.html +89 -0
- pygitweb/templates/project_settings.html +13 -0
- pygitweb/templates/pygit2_settings.html +12 -0
- pygitweb/templates/pygitweb_settings.html +12 -0
- pygitweb/templates/search.html +77 -0
- pygitweb/templates/settings_form_field.html +27 -0
- pygitweb/templates/table.html +15 -0
- pygitweb/templates/user_profile.html +10 -0
- pygitweb/templates_env.py +21 -0
- pygitweb/timeline_cache.py +243 -0
- pygitweb/validation.py +75 -0
- pygitweb/validation_test.py +167 -0
- pygitweb-0.1.0.dist-info/METADATA +165 -0
- pygitweb-0.1.0.dist-info/RECORD +95 -0
- pygitweb-0.1.0.dist-info/WHEEL +5 -0
- pygitweb-0.1.0.dist-info/licenses/LICENSE +21 -0
- pygitweb-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-04/schema#",
|
|
3
|
+
"description": "",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"salt": {
|
|
7
|
+
"type": "string",
|
|
8
|
+
"pattern": "^[0-9a-fA-F]{16}$",
|
|
9
|
+
"description": "16-character hex salt applied to local password hashes."
|
|
10
|
+
},
|
|
11
|
+
"auth_mode": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"pattern": "^(oauth|local|both)$"
|
|
14
|
+
},
|
|
15
|
+
"oauth_provider": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"pattern": "^(google|github)?$"
|
|
18
|
+
},
|
|
19
|
+
"oauth_client_id": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"minLength": 0
|
|
22
|
+
},
|
|
23
|
+
"oauth_client_secret": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"minLength": 0
|
|
26
|
+
},
|
|
27
|
+
"oauth_redirect_uri": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"minLength": 0
|
|
30
|
+
},
|
|
31
|
+
"oauth_username_from": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"pattern": "^(sub|email|preffered_username|login|name)$"
|
|
34
|
+
},
|
|
35
|
+
"oauth_allowed_emails": {
|
|
36
|
+
"type": "array",
|
|
37
|
+
"description": "OAuth sign-in allowlist. Empty allows all. Entries support fnmatch globs (*, ?) on the normalized e-mail.",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"minLength": 1
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"oauth_permissions": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"description": "Map of permission keys to principal emails (or local usernames). Key * grants all permissions.",
|
|
46
|
+
"properties": {
|
|
47
|
+
"*": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"items": { "type": "string" }
|
|
50
|
+
},
|
|
51
|
+
"pgw.addprojects": {
|
|
52
|
+
"type": "array",
|
|
53
|
+
"items": { "type": "string" }
|
|
54
|
+
},
|
|
55
|
+
"pgw.settings": {
|
|
56
|
+
"type": "array",
|
|
57
|
+
"items": { "type": "string" }
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"additionalProperties": {
|
|
61
|
+
"type": "array",
|
|
62
|
+
"items": { "type": "string" },
|
|
63
|
+
"description": "Scoped keys: pgw.boards.{scope}, pgw.tasks.{scope}, pgw.comments.{scope}, pgw.hooks.{scope}, pgw.mr.create.{scope}, pgw.mr.merge.{scope} (fnmatch on project path)"
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"local_users": {
|
|
67
|
+
"type": "array",
|
|
68
|
+
"uniqueItems": true,
|
|
69
|
+
"minItems": 1,
|
|
70
|
+
"items": {
|
|
71
|
+
"required": [
|
|
72
|
+
"user"
|
|
73
|
+
],
|
|
74
|
+
"oneOf": [
|
|
75
|
+
{ "required": ["pass_hash"] },
|
|
76
|
+
{ "required": ["pass"] }
|
|
77
|
+
],
|
|
78
|
+
"properties": {
|
|
79
|
+
"user": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"minLength": 2
|
|
82
|
+
},
|
|
83
|
+
"pass": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"minLength": 8,
|
|
86
|
+
"description": "Plaintext password; hashed to pass_hash on first server start, then removed."
|
|
87
|
+
},
|
|
88
|
+
"pass_hash": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"minLength": 1,
|
|
91
|
+
"description": "scrypt password hash (written by the server)."
|
|
92
|
+
},
|
|
93
|
+
"email": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"format": "email",
|
|
96
|
+
"description": "Optional e-mail for Gravatar and permission grants by principal e-mail."
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"required": [
|
|
103
|
+
"auth_mode",
|
|
104
|
+
"salt"
|
|
105
|
+
]
|
|
106
|
+
}
|
pygitweb/auth_config.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication settings loaded from ~/.pygitweb/auth.json (or PYGITWEB_AUTH_CONFIG).
|
|
3
|
+
|
|
4
|
+
On first run with auth enabled, creates the file with generated local credentials.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
import stat
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
|
|
18
|
+
|
|
19
|
+
from pygitweb.config import Settings
|
|
20
|
+
from pygitweb.password_hash import hash_password
|
|
21
|
+
from pygitweb.permissions import PermissionsMap
|
|
22
|
+
|
|
23
|
+
DEFAULT_AUTH_CONFIG_PATH = Path.home() / ".pygitweb" / "auth.json"
|
|
24
|
+
_AUTH_CONFIG_MODE = 0o600
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_windows() -> bool:
|
|
28
|
+
return sys.platform == "win32"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _auth_config_mode(path: Path) -> int:
|
|
32
|
+
return stat.S_IMODE(path.stat().st_mode)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _set_auth_config_mode(path: Path) -> None:
|
|
36
|
+
if _is_windows():
|
|
37
|
+
return
|
|
38
|
+
os.chmod(path, _AUTH_CONFIG_MODE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthConfigPermissionError(RuntimeError):
|
|
42
|
+
"""Auth config file exists but is not mode 0600 (non-Windows only)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _require_auth_config_mode(path: Path) -> None:
|
|
46
|
+
if _is_windows():
|
|
47
|
+
return
|
|
48
|
+
mode = _auth_config_mode(path)
|
|
49
|
+
if mode != _AUTH_CONFIG_MODE:
|
|
50
|
+
raise AuthConfigPermissionError(
|
|
51
|
+
f"PyGitWeb: auth config {path} must have mode 0600 (found {mode:#04o}). Run: chmod 600 {path}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
AuthMode = Literal["oauth", "local", "both"]
|
|
56
|
+
OAuthProvider = Literal["google", "github"]
|
|
57
|
+
OAuthUsernameFrom = Literal["sub", "email", "preffered_username", "login", "name"]
|
|
58
|
+
|
|
59
|
+
_AUTH_SALT_HEX_LEN = 16
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def generate_auth_salt() -> str:
|
|
63
|
+
return secrets.token_hex(_AUTH_SALT_HEX_LEN // 2)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LocalUser(BaseModel):
|
|
67
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
68
|
+
|
|
69
|
+
user: str = Field(min_length=2)
|
|
70
|
+
password: str | None = Field(default=None, alias="pass", serialization_alias="pass")
|
|
71
|
+
pass_hash: str = ""
|
|
72
|
+
email: str | None = None
|
|
73
|
+
|
|
74
|
+
@field_validator("email", mode="before")
|
|
75
|
+
@classmethod
|
|
76
|
+
def _normalize_email(cls, value: object) -> str | None:
|
|
77
|
+
if value is None or value == "":
|
|
78
|
+
return None
|
|
79
|
+
if isinstance(value, str):
|
|
80
|
+
stripped = value.strip()
|
|
81
|
+
return stripped or None
|
|
82
|
+
return str(value).strip() or None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AuthConfig(BaseModel):
|
|
86
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
87
|
+
|
|
88
|
+
salt: str = ""
|
|
89
|
+
auth_mode: AuthMode = "local"
|
|
90
|
+
oauth_provider: OAuthProvider | str = ""
|
|
91
|
+
oauth_client_id: str = ""
|
|
92
|
+
oauth_client_secret: str = ""
|
|
93
|
+
oauth_redirect_uri: str = ""
|
|
94
|
+
oauth_username_from: OAuthUsernameFrom = "email"
|
|
95
|
+
oauth_allowed_emails: list[str] = Field(default_factory=list)
|
|
96
|
+
oauth_permissions: PermissionsMap = Field(default_factory=PermissionsMap.empty)
|
|
97
|
+
local_users: list[LocalUser] = Field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
@field_validator("salt", mode="before")
|
|
100
|
+
@classmethod
|
|
101
|
+
def _validate_salt(cls, value: object) -> str:
|
|
102
|
+
if value is None or value == "":
|
|
103
|
+
return ""
|
|
104
|
+
salt = str(value).strip().lower()
|
|
105
|
+
if len(salt) != _AUTH_SALT_HEX_LEN or not all(c in "0123456789abcdef" for c in salt):
|
|
106
|
+
raise ValueError(f"salt must be a {_AUTH_SALT_HEX_LEN}-character hex string")
|
|
107
|
+
return salt
|
|
108
|
+
|
|
109
|
+
@field_validator("oauth_permissions", mode="before")
|
|
110
|
+
@classmethod
|
|
111
|
+
def _validate_oauth_permissions(cls, v: object) -> PermissionsMap | dict[str, list[str]]:
|
|
112
|
+
if isinstance(v, PermissionsMap):
|
|
113
|
+
return v
|
|
114
|
+
return PermissionsMap.model_validate(v)
|
|
115
|
+
|
|
116
|
+
@field_serializer("oauth_permissions")
|
|
117
|
+
def _serialize_oauth_permissions(self, value: PermissionsMap) -> dict[str, list[str]]:
|
|
118
|
+
return value.root
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def auth_config_path(settings: Settings) -> Path:
|
|
122
|
+
if settings.AUTH_CONFIG:
|
|
123
|
+
return Path(settings.AUTH_CONFIG).expanduser()
|
|
124
|
+
return DEFAULT_AUTH_CONFIG_PATH
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def default_auth_config(*, admin_password: str, salt: str) -> AuthConfig:
|
|
128
|
+
return AuthConfig(
|
|
129
|
+
salt=salt,
|
|
130
|
+
auth_mode="local",
|
|
131
|
+
oauth_provider="",
|
|
132
|
+
oauth_client_id="",
|
|
133
|
+
oauth_client_secret="",
|
|
134
|
+
oauth_redirect_uri="",
|
|
135
|
+
oauth_username_from="email",
|
|
136
|
+
oauth_allowed_emails=[],
|
|
137
|
+
oauth_permissions=PermissionsMap.empty(),
|
|
138
|
+
local_users=[LocalUser(user="admin", pass_hash=hash_password(admin_password, config_salt=salt))],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def write_auth_config(path: Path, config: AuthConfig) -> None:
|
|
143
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
path.write_text(
|
|
145
|
+
json.dumps(config.model_dump(by_alias=True, mode="json", exclude_none=True), indent=2) + "\n",
|
|
146
|
+
encoding="utf-8",
|
|
147
|
+
)
|
|
148
|
+
_set_auth_config_mode(path)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _ensure_auth_salt(path: Path, config: AuthConfig) -> AuthConfig:
|
|
152
|
+
if config.salt:
|
|
153
|
+
return config
|
|
154
|
+
salt = generate_auth_salt()
|
|
155
|
+
updated = config.model_copy(update={"salt": salt})
|
|
156
|
+
write_auth_config(path, updated)
|
|
157
|
+
return updated
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _migrate_plaintext_passwords(path: Path, config: AuthConfig) -> AuthConfig:
|
|
161
|
+
updated_users: list[LocalUser] = []
|
|
162
|
+
changed = False
|
|
163
|
+
for entry in config.local_users:
|
|
164
|
+
if not entry.password:
|
|
165
|
+
updated_users.append(entry)
|
|
166
|
+
continue
|
|
167
|
+
updated_users.append(
|
|
168
|
+
LocalUser(
|
|
169
|
+
user=entry.user,
|
|
170
|
+
pass_hash=hash_password(entry.password, config_salt=config.salt),
|
|
171
|
+
email=entry.email,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
changed = True
|
|
175
|
+
if not changed:
|
|
176
|
+
return config
|
|
177
|
+
migrated = config.model_copy(update={"local_users": updated_users})
|
|
178
|
+
write_auth_config(path, migrated)
|
|
179
|
+
print(
|
|
180
|
+
f"WARNING: hashed plaintext password(s) in {path}.\n",
|
|
181
|
+
file=sys.stderr,
|
|
182
|
+
)
|
|
183
|
+
return migrated
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_auth_config_file(path: Path) -> AuthConfig:
|
|
187
|
+
return AuthConfig.model_validate_json(path.read_text(encoding="utf-8"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_local_auth_available(config: AuthConfig) -> bool:
|
|
191
|
+
if config.auth_mode not in ("local", "both"):
|
|
192
|
+
return False
|
|
193
|
+
return any(u.pass_hash or u.password for u in config.local_users)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def is_oauth_auth_available(config: AuthConfig) -> bool:
|
|
197
|
+
if config.auth_mode not in ("oauth", "both"):
|
|
198
|
+
return False
|
|
199
|
+
provider = str(config.oauth_provider).strip()
|
|
200
|
+
return bool(
|
|
201
|
+
provider in ("google", "github")
|
|
202
|
+
and config.oauth_client_id.strip()
|
|
203
|
+
and config.oauth_client_secret.strip()
|
|
204
|
+
and config.oauth_redirect_uri.strip()
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def is_auth_configured(config: AuthConfig) -> bool:
|
|
209
|
+
if config.auth_mode == "local":
|
|
210
|
+
return is_local_auth_available(config)
|
|
211
|
+
if config.auth_mode == "oauth":
|
|
212
|
+
return is_oauth_auth_available(config)
|
|
213
|
+
return is_local_auth_available(config) or is_oauth_auth_available(config)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def init_auth_config(settings: Settings) -> AuthConfig:
|
|
217
|
+
global auth_config
|
|
218
|
+
path = auth_config_path(settings)
|
|
219
|
+
if path.is_file():
|
|
220
|
+
if settings.AUTH:
|
|
221
|
+
_require_auth_config_mode(path)
|
|
222
|
+
auth_config = _migrate_plaintext_passwords(
|
|
223
|
+
path,
|
|
224
|
+
_ensure_auth_salt(path, load_auth_config_file(path)),
|
|
225
|
+
)
|
|
226
|
+
return auth_config
|
|
227
|
+
if not settings.AUTH:
|
|
228
|
+
auth_config = AuthConfig(auth_mode="local", local_users=[])
|
|
229
|
+
return auth_config
|
|
230
|
+
password = secrets.token_urlsafe(16)
|
|
231
|
+
salt = generate_auth_salt()
|
|
232
|
+
auth_config = default_auth_config(admin_password=password, salt=salt)
|
|
233
|
+
write_auth_config(path, auth_config)
|
|
234
|
+
print(
|
|
235
|
+
f"WARNING: PyGitWeb: created auth config at {path}\n"
|
|
236
|
+
" Set PYGITWEB_AUTH=0 to disable auth, or edit this file for fixed credentials.\n",
|
|
237
|
+
file=sys.stderr,
|
|
238
|
+
)
|
|
239
|
+
print(f" Username: admin\n Password: {password}\n", file=sys.stderr)
|
|
240
|
+
return auth_config
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
auth_config: AuthConfig = AuthConfig(auth_mode="local", local_users=[])
|
pygitweb/auth_oauth.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth2 authorization-code flow (Google / GitHub) for browser login.
|
|
3
|
+
|
|
4
|
+
After the IdP redirects back, PyGitWeb issues the same opaque in-process session id used
|
|
5
|
+
for local login (cookie / Bearer), not an IdP access token. FastAPI's OAuth2* security
|
|
6
|
+
classes only extract credentials from requests; they do not implement this redirect flow.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import fnmatch
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
import secrets
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Annotated, Any
|
|
20
|
+
from urllib.parse import urlencode
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
from fastapi import APIRouter, HTTPException, Query, Request, status
|
|
24
|
+
from fastapi.responses import RedirectResponse
|
|
25
|
+
|
|
26
|
+
from pygitweb.auth_config import AuthConfig, OAuthUsernameFrom, auth_config, is_oauth_auth_available
|
|
27
|
+
from pygitweb.config import settings
|
|
28
|
+
from pygitweb.sessions import create_session
|
|
29
|
+
|
|
30
|
+
OAUTH_STATE_COOKIE_NAME = "pygitweb_oauth_state"
|
|
31
|
+
OAUTH_STATE_MAX_AGE = 600
|
|
32
|
+
ACCESS_TOKEN_COOKIE_NAME = "pygitweb_access_token"
|
|
33
|
+
LOGIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
|
34
|
+
|
|
35
|
+
TAG_AUTH_OAUTH = "auth - OAuth"
|
|
36
|
+
|
|
37
|
+
oauth_router = APIRouter()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _safe_next_url(next_raw: str | None) -> str:
|
|
41
|
+
n = (next_raw or "/").strip()
|
|
42
|
+
if not n.startswith("/") or n.startswith("//"):
|
|
43
|
+
return "/"
|
|
44
|
+
return n
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class _OAuthProvider:
|
|
49
|
+
authorize_url: str
|
|
50
|
+
token_url: str
|
|
51
|
+
scopes: str
|
|
52
|
+
userinfo_url: str | None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_PROVIDERS: dict[str, _OAuthProvider] = {
|
|
56
|
+
"google": _OAuthProvider(
|
|
57
|
+
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
58
|
+
token_url="https://oauth2.googleapis.com/token",
|
|
59
|
+
scopes="openid email profile",
|
|
60
|
+
userinfo_url="https://openidconnect.googleapis.com/v1/userinfo",
|
|
61
|
+
),
|
|
62
|
+
"github": _OAuthProvider(
|
|
63
|
+
authorize_url="https://github.com/login/oauth/authorize",
|
|
64
|
+
token_url="https://github.com/login/oauth/access_token",
|
|
65
|
+
scopes="read:user user:email",
|
|
66
|
+
userinfo_url=None,
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _provider(config: AuthConfig) -> _OAuthProvider:
|
|
72
|
+
name = str(config.oauth_provider).strip()
|
|
73
|
+
prov = _PROVIDERS.get(name)
|
|
74
|
+
if prov is None:
|
|
75
|
+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")
|
|
76
|
+
return prov
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _seal_oauth_state(payload: dict[str, Any], secret: str) -> str:
|
|
80
|
+
body = {**payload, "exp": time.time() + OAUTH_STATE_MAX_AGE}
|
|
81
|
+
data = json.dumps(body, separators=(",", ":")).encode()
|
|
82
|
+
sig = hmac.new(secret.encode(), data, hashlib.sha256).digest()
|
|
83
|
+
return base64.urlsafe_b64encode(data + b"." + base64.urlsafe_b64encode(sig)).decode()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _unseal_oauth_state(token: str, secret: str) -> dict[str, Any]:
|
|
87
|
+
try:
|
|
88
|
+
raw = base64.urlsafe_b64decode(token.encode())
|
|
89
|
+
data, sig_b64 = raw.rsplit(b".", 1)
|
|
90
|
+
sig = base64.urlsafe_b64decode(sig_b64)
|
|
91
|
+
expected = hmac.new(secret.encode(), data, hashlib.sha256).digest()
|
|
92
|
+
if not hmac.compare_digest(sig, expected):
|
|
93
|
+
raise ValueError("invalid signature")
|
|
94
|
+
payload: dict[str, Any] = json.loads(data)
|
|
95
|
+
if float(payload.get("exp", 0)) < time.time():
|
|
96
|
+
raise ValueError("expired")
|
|
97
|
+
return payload
|
|
98
|
+
except (ValueError, json.JSONDecodeError, KeyError) as exc:
|
|
99
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state") from exc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pkce_pair() -> tuple[str, str]:
|
|
103
|
+
verifier = secrets.token_urlsafe(48)
|
|
104
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
105
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
106
|
+
return verifier, challenge
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _claim(profile: dict[str, Any], claim: OAuthUsernameFrom) -> str | None:
|
|
110
|
+
if claim == "sub":
|
|
111
|
+
raw = profile.get("sub") or profile.get("id")
|
|
112
|
+
elif claim == "email":
|
|
113
|
+
raw = profile.get("email")
|
|
114
|
+
elif claim == "preffered_username":
|
|
115
|
+
raw = profile.get("preferred_username") or profile.get("preffered_username")
|
|
116
|
+
elif claim == "login":
|
|
117
|
+
raw = profile.get("login")
|
|
118
|
+
elif claim == "name":
|
|
119
|
+
raw = profile.get("name")
|
|
120
|
+
else:
|
|
121
|
+
raw = None
|
|
122
|
+
if raw is None:
|
|
123
|
+
return None
|
|
124
|
+
return str(raw)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def oauth_username_from_profile(config: AuthConfig, profile: dict[str, Any]) -> str:
|
|
128
|
+
username = _claim(profile, config.oauth_username_from)
|
|
129
|
+
if username:
|
|
130
|
+
return username
|
|
131
|
+
for fallback in ("sub", "email", "login", "id"):
|
|
132
|
+
val = profile.get(fallback)
|
|
133
|
+
if val is not None:
|
|
134
|
+
return str(val)
|
|
135
|
+
return "unknown"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def is_oauth_email_allowed(config: AuthConfig, email: str | None) -> bool:
|
|
139
|
+
if not config.oauth_allowed_emails:
|
|
140
|
+
return True
|
|
141
|
+
if not email:
|
|
142
|
+
return False
|
|
143
|
+
normalized = email.strip().lower()
|
|
144
|
+
patterns = [p.strip().lower() for p in config.oauth_allowed_emails if p.strip()]
|
|
145
|
+
return any(fnmatch.fnmatchcase(normalized, pattern) for pattern in patterns)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def _exchange_code(
|
|
149
|
+
config: AuthConfig,
|
|
150
|
+
*,
|
|
151
|
+
code: str,
|
|
152
|
+
code_verifier: str,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
prov = _provider(config)
|
|
155
|
+
data = {
|
|
156
|
+
"grant_type": "authorization_code",
|
|
157
|
+
"code": code,
|
|
158
|
+
"redirect_uri": config.oauth_redirect_uri,
|
|
159
|
+
"client_id": config.oauth_client_id,
|
|
160
|
+
"client_secret": config.oauth_client_secret,
|
|
161
|
+
"code_verifier": code_verifier,
|
|
162
|
+
}
|
|
163
|
+
headers = {"Accept": "application/json"}
|
|
164
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
165
|
+
resp = await client.post(prov.token_url, data=data, headers=headers)
|
|
166
|
+
resp.raise_for_status()
|
|
167
|
+
out = resp.json()
|
|
168
|
+
if not isinstance(out, dict):
|
|
169
|
+
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid token response from provider")
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def _fetch_google_profile(access_token: str) -> dict[str, Any]:
|
|
174
|
+
prov = _provider(auth_config)
|
|
175
|
+
assert prov.userinfo_url is not None
|
|
176
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
177
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
178
|
+
resp = await client.get(prov.userinfo_url, headers=headers)
|
|
179
|
+
resp.raise_for_status()
|
|
180
|
+
profile = resp.json()
|
|
181
|
+
if not isinstance(profile, dict):
|
|
182
|
+
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid userinfo response")
|
|
183
|
+
return profile
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def _fetch_github_profile(access_token: str) -> dict[str, Any]:
|
|
187
|
+
headers = {
|
|
188
|
+
"Authorization": f"Bearer {access_token}",
|
|
189
|
+
"Accept": "application/vnd.github+json",
|
|
190
|
+
}
|
|
191
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
192
|
+
user_resp = await client.get("https://api.github.com/user", headers=headers)
|
|
193
|
+
user_resp.raise_for_status()
|
|
194
|
+
profile = user_resp.json()
|
|
195
|
+
if not isinstance(profile, dict):
|
|
196
|
+
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid GitHub user response")
|
|
197
|
+
if profile.get("email"):
|
|
198
|
+
return profile
|
|
199
|
+
email_resp = await client.get("https://api.github.com/user/emails", headers=headers)
|
|
200
|
+
if email_resp.is_success:
|
|
201
|
+
emails = email_resp.json()
|
|
202
|
+
if isinstance(emails, list):
|
|
203
|
+
for entry in emails:
|
|
204
|
+
if isinstance(entry, dict) and entry.get("primary") and entry.get("verified"):
|
|
205
|
+
profile = {**profile, "email": entry.get("email")}
|
|
206
|
+
break
|
|
207
|
+
return profile
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def _fetch_profile(config: AuthConfig, token_payload: dict[str, Any]) -> dict[str, Any]:
|
|
211
|
+
access_token = token_payload.get("access_token")
|
|
212
|
+
if not isinstance(access_token, str) or not access_token:
|
|
213
|
+
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Provider did not return an access token")
|
|
214
|
+
provider = str(config.oauth_provider).strip()
|
|
215
|
+
if provider == "google":
|
|
216
|
+
return await _fetch_google_profile(access_token)
|
|
217
|
+
if provider == "github":
|
|
218
|
+
return await _fetch_github_profile(access_token)
|
|
219
|
+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _profile_subject(profile: dict[str, Any]) -> str | None:
|
|
223
|
+
for key in ("sub", "id"):
|
|
224
|
+
val = profile.get(key)
|
|
225
|
+
if val is not None:
|
|
226
|
+
return str(val)
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _profile_email(profile: dict[str, Any]) -> str | None:
|
|
231
|
+
email = profile.get("email")
|
|
232
|
+
return str(email) if email else None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@oauth_router.get(
|
|
236
|
+
"/auth/oauth/start",
|
|
237
|
+
tags=[TAG_AUTH_OAUTH],
|
|
238
|
+
summary="Start external OAuth sign-in",
|
|
239
|
+
description=(
|
|
240
|
+
"Browser-only. Redirects to the configured provider (Google or GitHub). "
|
|
241
|
+
"On success, /auth/oauth/callback sets the same pygitweb_access_token session cookie as local login."
|
|
242
|
+
),
|
|
243
|
+
responses={303: {"description": "Redirect to identity provider"}},
|
|
244
|
+
)
|
|
245
|
+
async def oauth_start(
|
|
246
|
+
next: Annotated[str, Query()] = "/",
|
|
247
|
+
) -> RedirectResponse:
|
|
248
|
+
if not settings.AUTH:
|
|
249
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
|
|
250
|
+
if not is_oauth_auth_available(auth_config):
|
|
251
|
+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
|
|
252
|
+
prov = _provider(auth_config)
|
|
253
|
+
verifier, challenge = _pkce_pair()
|
|
254
|
+
idp_state = secrets.token_urlsafe(32)
|
|
255
|
+
sealed = _seal_oauth_state(
|
|
256
|
+
{
|
|
257
|
+
"state": idp_state,
|
|
258
|
+
"next": _safe_next_url(next),
|
|
259
|
+
"code_verifier": verifier,
|
|
260
|
+
},
|
|
261
|
+
auth_config.oauth_client_secret,
|
|
262
|
+
)
|
|
263
|
+
params = {
|
|
264
|
+
"response_type": "code",
|
|
265
|
+
"client_id": auth_config.oauth_client_id,
|
|
266
|
+
"redirect_uri": auth_config.oauth_redirect_uri,
|
|
267
|
+
"scope": prov.scopes,
|
|
268
|
+
"state": idp_state,
|
|
269
|
+
"code_challenge": challenge,
|
|
270
|
+
"code_challenge_method": "S256",
|
|
271
|
+
}
|
|
272
|
+
url = f"{prov.authorize_url}?{urlencode(params)}"
|
|
273
|
+
resp = RedirectResponse(url=url, status_code=303)
|
|
274
|
+
resp.set_cookie(
|
|
275
|
+
key=OAUTH_STATE_COOKIE_NAME,
|
|
276
|
+
value=sealed,
|
|
277
|
+
httponly=True,
|
|
278
|
+
samesite="lax",
|
|
279
|
+
max_age=OAUTH_STATE_MAX_AGE,
|
|
280
|
+
path="/",
|
|
281
|
+
)
|
|
282
|
+
return resp
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@oauth_router.get(
|
|
286
|
+
"/auth/oauth/callback",
|
|
287
|
+
tags=[TAG_AUTH_OAUTH],
|
|
288
|
+
summary="OAuth provider callback",
|
|
289
|
+
description=(
|
|
290
|
+
"Browser-only. Called by the identity provider after sign-in. "
|
|
291
|
+
"Validates state, exchanges the code, and sets the session cookie. Not intended for Try it out."
|
|
292
|
+
),
|
|
293
|
+
responses={303: {"description": "Redirect to original next URL with session cookie set"}},
|
|
294
|
+
)
|
|
295
|
+
async def oauth_callback(
|
|
296
|
+
request: Request,
|
|
297
|
+
code: Annotated[str | None, Query()] = None,
|
|
298
|
+
state: Annotated[str | None, Query()] = None,
|
|
299
|
+
error: Annotated[str | None, Query()] = None,
|
|
300
|
+
) -> RedirectResponse:
|
|
301
|
+
if not settings.AUTH:
|
|
302
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
|
|
303
|
+
if not is_oauth_auth_available(auth_config):
|
|
304
|
+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
|
|
305
|
+
if error:
|
|
306
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"OAuth provider error: {error}")
|
|
307
|
+
if not code or not state:
|
|
308
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth code or state")
|
|
309
|
+
sealed = request.cookies.get(OAUTH_STATE_COOKIE_NAME)
|
|
310
|
+
if not sealed:
|
|
311
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth state cookie")
|
|
312
|
+
payload = _unseal_oauth_state(sealed, auth_config.oauth_client_secret)
|
|
313
|
+
if not hmac.compare_digest(str(payload.get("state", "")), state):
|
|
314
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OAuth state mismatch")
|
|
315
|
+
code_verifier = payload.get("code_verifier")
|
|
316
|
+
if not isinstance(code_verifier, str) or not code_verifier:
|
|
317
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state payload")
|
|
318
|
+
next_url = _safe_next_url(str(payload.get("next") or "/"))
|
|
319
|
+
|
|
320
|
+
token_payload = await _exchange_code(auth_config, code=code, code_verifier=code_verifier)
|
|
321
|
+
profile = await _fetch_profile(auth_config, token_payload)
|
|
322
|
+
email = _profile_email(profile)
|
|
323
|
+
if not is_oauth_email_allowed(auth_config, email):
|
|
324
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email is not allowed to sign in")
|
|
325
|
+
username = oauth_username_from_profile(auth_config, profile)
|
|
326
|
+
subject = _profile_subject(profile)
|
|
327
|
+
sid = create_session(
|
|
328
|
+
username,
|
|
329
|
+
subject=subject,
|
|
330
|
+
email=email,
|
|
331
|
+
auth_method="oauth",
|
|
332
|
+
)
|
|
333
|
+
resp = RedirectResponse(url=next_url, status_code=303)
|
|
334
|
+
resp.set_cookie(
|
|
335
|
+
key=ACCESS_TOKEN_COOKIE_NAME,
|
|
336
|
+
value=sid,
|
|
337
|
+
httponly=True,
|
|
338
|
+
samesite="lax",
|
|
339
|
+
max_age=LOGIN_COOKIE_MAX_AGE,
|
|
340
|
+
path="/",
|
|
341
|
+
)
|
|
342
|
+
resp.delete_cookie(key=OAUTH_STATE_COOKIE_NAME, path="/")
|
|
343
|
+
return resp
|