Coverage for src/lexigram/admin/controllers/setup.py: 24%

126 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:39 +0800

1"""First-run setup controller for Lexigram Admin. 

2 

3Provides the initial account creation wizard shown when no admin users exist. 

4The SetupMiddleware redirects all admin requests here until at least one 

5admin account has been created. 

6""" 

7 

8from __future__ import annotations 

9 

10import hmac 

11import secrets 

12from urllib.parse import quote_plus 

13 

14from starlette.requests import Request 

15from starlette.responses import HTMLResponse, RedirectResponse 

16 

17from lexigram.admin.auth.protocols import ( 

18 AdminAuditLogServiceProtocol, 

19 AdminCsrfServiceProtocol, 

20 AdminEmailVerificationServiceProtocol, 

21 AdminPasswordPolicyServiceProtocol, 

22) 

23from lexigram.admin.auth.store import AdminUserStoreProtocol 

24from lexigram.admin.auth.types import AdminSecurityEventType 

25from lexigram.admin.config import AdminConfig, AdminRbacConfig 

26from lexigram.admin.controllers.base import AdminController 

27from lexigram.admin.engine.renderer import AdminRenderer 

28from lexigram.admin.lib.template import render_setup_page 

29from lexigram.contracts.core import TaskManagerProtocol 

30from lexigram.contracts.web import get, post 

31from lexigram.di.decorators import inject 

32from lexigram.logging import get_logger 

33 

34logger = get_logger(__name__) 

35 

36 

37@inject 

38class SetupController(AdminController): 

39 """First-run setup wizard controller. 

40 

41 Provides: 

42 - GET /setup — Display account creation form 

43 - POST /setup — Create the first admin account 

44 """ 

45 

46 prefix = "" 

47 

48 def __init__( 

49 self, 

50 config: AdminConfig, 

51 user_store: AdminUserStoreProtocol, 

52 password_policy_service: AdminPasswordPolicyServiceProtocol, 

53 audit_service: AdminAuditLogServiceProtocol, 

54 csrf_service: AdminCsrfServiceProtocol, 

55 renderer: AdminRenderer, 

56 task_manager: TaskManagerProtocol | None = None, 

57 email_verification_service: AdminEmailVerificationServiceProtocol | None = None, 

58 rbac_config: AdminRbacConfig | None = None, 

59 ) -> None: 

60 """Initialise setup controller. 

61 

62 Args: 

63 config: Admin configuration; the optional setup token is read 

64 from ``config.auth.security.setup_token`` (single enforcement 

65 source, env var ``ADMIN_SETUP_TOKEN`` honored via alias). 

66 user_store: Store used to check and create admin accounts. 

67 password_policy_service: Validates passwords against all configured 

68 policy rules; returns every violation, not just the first. 

69 audit_service: Records security events; guaranteed never to raise. 

70 csrf_service: Generates and validates CSRF tokens for the 

71 pre-session setup form (bypassed by the CSRF middleware). 

72 renderer: AdminRenderer required by AdminController base. 

73 task_manager: Optional; injected by container in production. 

74 email_verification_service: Optional email verification 

75 orchestrator; when present and the gate applies to the new 

76 account, a verification email is sent and the user is 

77 informed after creation. 

78 rbac_config: Optional; the resolved RBAC config whose 

79 ``super_admin_role`` names the role granted to the first 

80 admin account. 

81 """ 

82 super().__init__(renderer, task_manager) 

83 self._config = config 

84 self._user_store = user_store 

85 self._password_policy_service = password_policy_service 

86 self._audit_service = audit_service 

87 self._csrf_service = csrf_service 

88 self._email_verification_service = email_verification_service 

89 self._rbac_config = rbac_config 

90 

91 def _fresh_csrf(self, request: Request) -> str: 

92 """Generate a fresh session-scoped CSRF token for the setup form.""" 

93 csrf_session_id = secrets.token_urlsafe(16) 

94 request.session["csrf_session_id"] = csrf_session_id 

95 return self._csrf_service.generate_token(csrf_session_id) 

96 

97 # ------------------------------------------------------------------ 

98 # GET /setup 

99 # ------------------------------------------------------------------ 

100 

101 @get("/setup") 

102 async def setup_form(self, request: Request) -> HTMLResponse | RedirectResponse: 

103 """Display the first-run setup form. 

104 

105 If at least one admin account already exists, a locked message is shown 

106 so the user knows to log in with their existing credentials. 

107 

108 Args: 

109 request: Incoming HTTP request. 

110 

111 Returns: 

112 HTMLResponse with the rendered setup page. 

113 """ 

114 required_token = self._config.auth.security.setup_token 

115 try: 

116 count = await self._user_store.get_admin_count() 

117 except (RuntimeError, ValueError, OSError) as e: 

118 logger.warning("setup.count_failed error=%s", e) 

119 html = render_setup_page( 

120 error="Unable to verify setup status. Database may be unavailable.", 

121 csrf_token=self._fresh_csrf(request), 

122 setup_token_required=bool(required_token), 

123 ) 

124 return HTMLResponse(content=html, status_code=503) 

125 if count > 0: 

126 html = render_setup_page( 

127 locked=True, 

128 error="Setup is already complete. Please log in with your existing account.", 

129 ) 

130 return HTMLResponse(content=html, status_code=200) 

131 

132 error = request.query_params.get("error", "") 

133 html = render_setup_page( 

134 error=error, 

135 csrf_token=self._fresh_csrf(request), 

136 setup_token_required=bool(required_token), 

137 ) 

138 return HTMLResponse(content=html) 

139 

140 # ------------------------------------------------------------------ 

141 # POST /setup 

142 # ------------------------------------------------------------------ 

143 

144 @post("/setup") 

145 async def setup_submit(self, request: Request) -> HTMLResponse | RedirectResponse: 

146 """Process the first-run setup form and create the initial admin account. 

147 

148 Validates the optional setup token, enforces the full password policy 

149 (all violations reported simultaneously), hashes the password with 

150 bcrypt, persists the user, audits the outcome, then redirects to the 

151 login page. 

152 

153 Args: 

154 request: Incoming HTTP request carrying form data. 

155 

156 Returns: 

157 RedirectResponse to ``/admin/login?next=/admin/`` on success, or 

158 an HTMLResponse re-rendering the setup form with error details on 

159 any validation or persistence failure. 

160 """ 

161 required_token = self._config.auth.security.setup_token 

162 try: 

163 count = await self._user_store.get_admin_count() 

164 except (RuntimeError, ValueError, OSError) as e: 

165 logger.warning("setup.count_failed error=%s", e) 

166 html = render_setup_page( 

167 error="Unable to verify setup status. Database may be unavailable.", 

168 csrf_token=self._fresh_csrf(request), 

169 setup_token_required=bool(required_token), 

170 ) 

171 return HTMLResponse(content=html, status_code=503) 

172 if count > 0: 

173 html = render_setup_page( 

174 locked=True, 

175 error="Setup is already complete. Please log in with your existing account.", 

176 ) 

177 return HTMLResponse(content=html, status_code=200) 

178 

179 form_data = await request.form() 

180 name = str(form_data.get("name", "")).strip() 

181 email = str(form_data.get("email", "")).strip() 

182 password = str(form_data.get("password", "")).strip() 

183 confirm = str(form_data.get("confirm_password", "")).strip() 

184 setup_token_input = str(form_data.get("setup_token", "")).strip() 

185 csrf_token = str(form_data.get("csrf_token", "")) 

186 

187 ip = self._get_client_ip(request) 

188 user_agent = request.headers.get("user-agent", "") 

189 

190 # ── CSRF validation ──────────────────────────────────────────── 

191 csrf_session_id = request.session.get("csrf_session_id", "") 

192 if not csrf_session_id or not self._csrf_service.validate_token( 

193 csrf_session_id, csrf_token 

194 ): 

195 logger.warning("setup.csrf_validation_failed", ip=ip) 

196 html = render_setup_page( 

197 error="Invalid or expired security token. Please reload the page and try again.", 

198 csrf_token=self._fresh_csrf(request), 

199 setup_token_required=bool(required_token), 

200 ) 

201 return HTMLResponse(content=html, status_code=422) 

202 

203 # ── Optional setup-token guard ───────────────────────────────── 

204 required_token_str = ( 

205 required_token.get_secret_value() 

206 if required_token is not None 

207 else None 

208 ) 

209 if required_token_str and not hmac.compare_digest( 

210 setup_token_input, required_token_str 

211 ): 

212 logger.warning("setup.token_mismatch", ip=ip) 

213 await self._audit_service.log_event( 

214 event_type=AdminSecurityEventType.SETUP_BLOCKED, 

215 ip_address=ip, 

216 user_agent=user_agent, 

217 success=False, 

218 metadata={"reason": "invalid_setup_token"}, 

219 ) 

220 html = render_setup_page( 

221 error="Invalid setup token.", 

222 csrf_token=self._fresh_csrf(request), 

223 setup_token_required=bool(required_token), 

224 ) 

225 return HTMLResponse(content=html, status_code=403) 

226 

227 # ── Basic field presence ─────────────────────────────────────── 

228 if not name or not email or not password: 

229 html = render_setup_page( 

230 error="All fields are required.", 

231 csrf_token=self._fresh_csrf(request), 

232 setup_token_required=bool(required_token), 

233 ) 

234 return HTMLResponse(content=html, status_code=422) 

235 

236 if password != confirm: 

237 html = render_setup_page( 

238 error="Passwords do not match.", 

239 csrf_token=self._fresh_csrf(request), 

240 setup_token_required=bool(required_token), 

241 ) 

242 return HTMLResponse(content=html, status_code=422) 

243 

244 # ── Full password policy validation (all violations) ─────────── 

245 policy_result = self._password_policy_service.validate(password, email=email) 

246 if not policy_result.is_valid: 

247 violation_lines = "\n".join( 

248 f"{v.message}" for v in policy_result.violations 

249 ) 

250 html = render_setup_page( 

251 error=violation_lines, 

252 csrf_token=self._fresh_csrf(request), 

253 setup_token_required=bool(required_token), 

254 ) 

255 return HTMLResponse(content=html, status_code=422) 

256 

257 # ── Hash and persist ─────────────────────────────────────────── 

258 hashed_password = _hash_password(password) 

259 

260 try: 

261 created_result = await self._user_store.claim_first_admin( 

262 name=name, 

263 email=email, 

264 hashed_password=hashed_password, 

265 roles=[(self._rbac_config or AdminRbacConfig()).super_admin_role], 

266 ) 

267 except Exception as exc: 

268 # Treat any persistence failure (duplicate email, DB error, etc.) 

269 # as a non-fatal setup error that is shown back to the user. 

270 logger.error("setup.create_user_failed", email=email, error=str(exc)) 

271 html = render_setup_page( 

272 error=f"Failed to create account: {exc}", 

273 csrf_token=self._fresh_csrf(request), 

274 setup_token_required=bool(required_token), 

275 ) 

276 return HTMLResponse(content=html, status_code=422) 

277 

278 if created_result.is_err(): 

279 # Another submission created the first admin between the 

280 # pre-flight count check and this insert — lock the wizard. 

281 html = render_setup_page( 

282 locked=True, 

283 error="Setup is already complete. Please log in with your existing account.", 

284 ) 

285 return HTMLResponse(content=html, status_code=200) 

286 created = created_result.unwrap() 

287 

288 logger.info("setup.first_admin_created", email=email) 

289 

290 await self._audit_service.log_event( 

291 event_type=AdminSecurityEventType.SETUP_COMPLETED, 

292 ip_address=ip, 

293 user_agent=user_agent, 

294 success=True, 

295 metadata={"email": email}, 

296 ) 

297 

298 notice = "" 

299 user_id = str(getattr(created, "user_id", "") or getattr(created, "id", "")) 

300 if ( 

301 self._email_verification_service is not None 

302 and user_id 

303 and await self._email_verification_service.is_required(user_id) 

304 ): 

305 send_result = await self._email_verification_service.send_verification( 

306 user_id=user_id, 

307 email=email, 

308 user_name=name, 

309 base_url=str(request.base_url), 

310 ip_address=ip, 

311 ) 

312 if send_result.is_ok(): 

313 notice = ( 

314 f"Account created successfully — a verification email was " 

315 f"sent to {email}. Please verify your email before signing in." 

316 ) 

317 else: 

318 logger.error( 

319 "setup.verification_send_failed", 

320 email=email, 

321 error=str(send_result.unwrap_err()), 

322 ) 

323 notice = ( 

324 "Account created successfully — email verification is " 

325 "enabled, and you will be asked to verify your email " 

326 "before signing in." 

327 ) 

328 

329 url = "/admin/login?next=/admin/" 

330 if notice: 

331 url += "&notice=" + quote_plus(notice) 

332 return RedirectResponse(url=url, status_code=302) 

333 

334 # ------------------------------------------------------------------ 

335 # Helpers 

336 # ------------------------------------------------------------------ 

337 

338 def _get_client_ip(self, request: Request) -> str: 

339 """Extract the real client IP from the request. 

340 

341 Prefers the first value of the ``X-Forwarded-For`` header when present 

342 (set by reverse proxies), falling back to the direct TCP peer address. 

343 

344 Args: 

345 request: Incoming HTTP request. 

346 

347 Returns: 

348 IP address string, or ``"unknown"`` when unavailable. 

349 """ 

350 forwarded = request.headers.get("x-forwarded-for", "") 

351 if forwarded: 

352 return forwarded.split(",")[0].strip() 

353 return request.client.host if request.client else "unknown" 

354 

355 

356def _hash_password(plain: str) -> str: 

357 """Hash a plain-text password using bcrypt. 

358 

359 Bcrypt with 12 rounds is used. A missing ``bcrypt`` package raises 

360 ``RuntimeError`` (fail-closed) instead of degrading to SHA-256. 

361 

362 Args: 

363 plain: Plain-text password string. 

364 

365 Returns: 

366 Hashed password string suitable for storage. 

367 

368 Raises: 

369 RuntimeError: When the ``bcrypt`` package is not installed. 

370 """ 

371 from lexigram.admin.lib.password import hash_password 

372 

373 return hash_password(plain) 

374 

375 

376__all__ = ["SetupController"]