1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
"""
OAuth2 password flow (tutorial-style) with credentials from PYGITWEB_ADMIN_USER / PYGITWEB_ADMIN_PASSWORD.
Browser login: GET/POST /login sets an HttpOnly cookie holding an opaque session id.
APIs accept Authorization: Bearer <same session id>. Sessions live in memory only (see pygitweb.sessions).
"""
from __future__ import annotations
import hmac
from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Query, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.security import APIKeyCookie, OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from pygitweb.config import settings
from pygitweb.sessions import create_session, get_session, revoke_session
from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
ACCESS_TOKEN_COOKIE_NAME = "pygitweb_access_token"
LOGIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
_access_token_cookie = APIKeyCookie(name=ACCESS_TOKEN_COOKIE_NAME, auto_error=False)
auth_router = APIRouter(tags=["auth"])
class User(BaseModel):
username: str
class UserInDB(User):
password: str
def get_auth_credentials() -> UserInDB | None:
if not settings.AUTH:
return None
pwd = settings.ADMIN_PASSWORD
if not pwd:
return None
return UserInDB(username=settings.ADMIN_USER, password=pwd)
def get_user(username: str) -> UserInDB | None:
stored = get_auth_credentials()
if stored is None:
return None
if not hmac.compare_digest(stored.username, username):
return None
return stored
def authenticate_user(username: str, password: str) -> User | None:
user = get_user(username)
if user is None:
return None
if not hmac.compare_digest(user.password, password):
return None
return User(username=user.username)
def decode_access_token(token: str) -> User | None:
stored = get_auth_credentials()
if stored is None:
return None
rec = get_session(token)
if rec is None:
return None
if not hmac.compare_digest(stored.username, rec.username):
return None
return User(username=stored.username)
def safe_next_url(next_raw: str | None) -> str:
n = (next_raw or "/").strip()
if not n.startswith("/") or n.startswith("//"):
return "/"
return n
def access_token_from_request(
bearer: Annotated[str | None, Depends(oauth2_scheme)],
cookie_token: Annotated[str | None, Depends(_access_token_cookie)],
) -> str | None:
b = bearer.strip() if bearer else None
c = cookie_token.strip() if cookie_token else None
return b or c
def ensure_active_user_if_auth_enabled(token: str | None) -> None:
if not settings.AUTH:
return
if get_auth_credentials() is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication enabled but ADMIN_USER / ADMIN_PASSWORD are not configured",
)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
if decode_access_token(token) is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
def require_active_user_if_auth_enabled(
token: Annotated[str | None, Depends(access_token_from_request)],
) -> None:
ensure_active_user_if_auth_enabled(token)
@auth_router.get("/login", response_class=HTMLResponse)
async def login_page(
next: Annotated[str, Query()] = "/",
) -> HTMLResponse:
if not settings.AUTH:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
next_safe = safe_next_url(next)
pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Sign in", site_name=settings.SITE_NAME)
body = env.get_template("login.html").render(
site_name=settings.SITE_NAME,
next=next_safe,
error=None,
)
return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
@auth_router.post("/login", response_model=None)
async def login_form_submit(
username: Annotated[str, Form()],
password: Annotated[str, Form()],
next: Annotated[str, Form()] = "/",
) -> HTMLResponse | RedirectResponse:
if not settings.AUTH:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
next_safe = safe_next_url(next)
user = authenticate_user(username.strip(), password)
if user is None:
pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Sign in", site_name=settings.SITE_NAME)
body = env.get_template("login.html").render(
site_name=settings.SITE_NAME,
next=next_safe,
error="Incorrect username or password.",
)
return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
sid = create_session(user.username)
resp = RedirectResponse(url=next_safe, status_code=303)
resp.set_cookie(
key=ACCESS_TOKEN_COOKIE_NAME,
value=sid,
httponly=True,
samesite="lax",
max_age=LOGIN_COOKIE_MAX_AGE,
path="/",
)
return resp
@auth_router.get("/logout")
async def logout_page(
token: Annotated[str | None, Depends(access_token_from_request)],
next: Annotated[str, Query()] = "/",
) -> RedirectResponse:
revoke_session(token)
resp = RedirectResponse(url=safe_next_url(next), status_code=303)
resp.delete_cookie(key=ACCESS_TOKEN_COOKIE_NAME, path="/")
return resp
@auth_router.post("/token")
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict[str, str]:
if not settings.AUTH:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
user = authenticate_user(form_data.username, form_data.password)
if user is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect username or password")
sid = create_session(user.username)
return {"access_token": sid, "token_type": "bearer"}
@auth_router.get("/auth/status")
async def auth_status(
token: Annotated[str | None, Depends(access_token_from_request)],
) -> dict[str, bool | str | None]:
"""Lightweight JSON for the navbar script (no 401 when anonymous)."""
if not settings.AUTH:
return {"auth_enabled": False, "username": None}
if not token:
return {"auth_enabled": True, "username": None}
u = decode_access_token(token)
if u is None:
return {"auth_enabled": True, "username": None}
return {"auth_enabled": True, "username": u.username}
@auth_router.get("/user", response_class=HTMLResponse, response_model=None)
async def user_account_page(
token: Annotated[str | None, Depends(access_token_from_request)],
) -> HTMLResponse | RedirectResponse:
if not settings.AUTH:
pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Account", site_name=settings.SITE_NAME)
body = '<p class="text-muted">Authentication is not enabled on this server.</p>'
return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
if get_auth_credentials() is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication is misconfigured",
)
u = decode_access_token(token or "") if token else None
if u is None:
return RedirectResponse(url="/login?next=/user", status_code=303)
pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Account", site_name=settings.SITE_NAME)
body = env.get_template("user_profile.html").render(username=u.username, site_name=settings.SITE_NAME)
return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
@auth_router.get("/users/me", response_model=User)
async def read_users_me(
token: Annotated[str | None, Depends(access_token_from_request)],
) -> User:
if not settings.AUTH:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
ensure_active_user_if_auth_enabled(token)
u = decode_access_token(token or "")
if u is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return u