Coverage for src/lexigram/admin/config/auth.py: 23%

62 statements  

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

1"""Authentication, MFA, email OTP, verification, and registration configurations.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import Any, Literal 

7 

8from lexigram.admin.config.security import AdminPasswordPolicyConfig, AdminSecurityConfig 

9from lexigram.domain import DomainModel 

10from lexigram.validation import ( 

11 Field, 

12 SecretStr, 

13 model_validator, 

14) 

15 

16 

17@dataclass(init=False) 

18class AdminMfaConfig(DomainModel): 

19 """Two-factor authentication (TOTP) configuration. 

20 

21 Controls whether TOTP 2FA is offered, the issuer label embedded in 

22 provisioning URIs, and the allowed clock-skew window for codes. 

23 """ 

24 

25 enabled: bool = Field(default=True, description="Enable TOTP 2FA") 

26 factor: str = Field( 

27 default="totp", 

28 description="Second factor used at login: 'totp' (authenticator app) or 'email' (one-time code)", 

29 ) 

30 issuer: str = Field( 

31 default="Lexigram Admin", 

32 description="TOTP issuer label shown in authenticator apps", 

33 ) 

34 skew: int = Field( 

35 default=1, 

36 ge=0, 

37 le=2, 

38 description="Allowed clock skew in 30 second steps", 

39 ) 

40 

41 

42@dataclass(init=False) 

43class AdminEmailOtpConfig(DomainModel): 

44 """Email one-time-password (login factor) configuration. 

45 

46 Controls whether the email-OTP factor is available, how long a code 

47 stays valid, and the minimum interval between sends. 

48 """ 

49 

50 enabled: bool = Field(default=True, description="Enable email OTP factor") 

51 ttl_minutes: int = Field( 

52 default=10, 

53 ge=1, 

54 le=60, 

55 description="Code validity window in minutes", 

56 ) 

57 resend_cooldown_seconds: int = Field( 

58 default=60, 

59 ge=5, 

60 le=600, 

61 description="Minimum seconds between email OTP sends", 

62 ) 

63 

64 

65@dataclass(init=False) 

66class AdminEmailVerificationConfig(DomainModel): 

67 """Email verification (login gate) configuration. 

68 

69 Controls the verify-your-email flow: whether it is offered, whether 

70 unverified users are blocked at login, and the verify-link lifetime. 

71 """ 

72 

73 enabled: bool = Field(default=True, description="Enable email verification flow") 

74 enforcement: bool = Field( 

75 default=True, 

76 description="Block login until the email is verified", 

77 ) 

78 token_ttl_hours: int = Field( 

79 default=24, 

80 ge=1, 

81 le=168, 

82 description="Verify link validity in hours", 

83 ) 

84 

85 

86@dataclass(init=False) 

87class AdminRegistrationConfig(DomainModel): 

88 """Self-service registration configuration. 

89 

90 Off by default — admin panels are typically invite-only. When enabled, 

91 ``GET/POST /admin/register`` becomes available and new accounts receive 

92 the configured default role. 

93 """ 

94 

95 enabled: bool = Field(default=False, description="Allow self-service registration") 

96 default_role: str = Field( 

97 default="admin", description="Role granted to new accounts" 

98 ) 

99 allowed_email_domains: list[str] = Field( 

100 default_factory=list, 

101 description="Restrict registration to these email domains (empty = any)", 

102 ) 

103 

104 

105@dataclass(init=False) 

106class AdminAuthConfig(DomainModel): 

107 """Authentication configuration.""" 

108 

109 enabled: bool = Field(default=True, description="Enable authentication") 

110 env: Literal["development", "staging", "production"] = Field( 

111 default="development", 

112 description="Deployment environment for cookie security defaults", 

113 ) 

114 session_secret: SecretStr = Field( 

115 default=SecretStr("change-me-in-production"), 

116 description="Session secret for signing", 

117 ) 

118 login_url: str = Field(default="/admin/login") 

119 logout_url: str = Field(default="/admin/logout") 

120 session_lifetime: int = Field(default=86400, ge=300) # 5 min minimum 

121 permission_cache_ttl: int = Field(default=300, ge=0) # 5 minutes 

122 

123 # Security settings 

124 idle_timeout: int = Field( 

125 default=3600, ge=60, description="Session idle timeout in seconds" 

126 ) 

127 csrf_token_lifetime: int = Field( 

128 default=3600, ge=60, description="CSRF token expiry in seconds" 

129 ) 

130 password_policy: AdminPasswordPolicyConfig = Field( 

131 default_factory=AdminPasswordPolicyConfig, 

132 ) 

133 security: AdminSecurityConfig = Field( 

134 default_factory=AdminSecurityConfig, 

135 ) 

136 mfa: AdminMfaConfig = Field(default_factory=AdminMfaConfig) 

137 email_otp: AdminEmailOtpConfig = Field(default_factory=AdminEmailOtpConfig) 

138 email_verification: AdminEmailVerificationConfig = Field( 

139 default_factory=AdminEmailVerificationConfig 

140 ) 

141 registration: AdminRegistrationConfig = Field( 

142 default_factory=AdminRegistrationConfig 

143 ) 

144 

145 # Users and Roles (Sync) 

146 users: list[Any] = Field(default_factory=list) 

147 roles: dict[str, Any] = Field(default_factory=dict) 

148 

149 # Identity bridge (spec D3): "internal" = framework admin_users table 

150 # (default); "app" = AdminPrincipalProviderProtocol implemented by the app. 

151 principal_source: Literal["internal", "app"] = Field(default="internal") 

152 

153 # OAuth/SSO (optional) 

154 oauth_enabled: bool = Field(default=False) 

155 oauth_providers: list[str] = Field(default_factory=list) 

156 

157 model_config = {"extra": "allow"} 

158 

159 @model_validator(mode="after") 

160 def validate_security(self) -> AdminAuthConfig: 

161 """Ensure secure settings in production.""" 

162 if not isinstance(self.session_secret, SecretStr): 

163 self.session_secret = SecretStr(self.session_secret) 

164 insecure_defaults = ( 

165 "change-me", 

166 "your-secret-key", 

167 "secret", 

168 "password", 

169 "change-me-in-production", 

170 ) 

171 

172 if ( 

173 self.env in {"production", "staging"} 

174 and self.session_secret.get_secret_value().lower() in insecure_defaults 

175 ): 

176 raise ValueError( 

177 "CRITICAL SECURITY ERROR: Default admin session_secret detected in " 

178 f"{self.env.upper()}.\n" 

179 "You MUST set a secure session secret via LEX_ADMIN__AUTH__SESSION_SECRET.", 

180 ) 

181 

182 if ( 

183 self.env in {"production", "staging"} 

184 and self.oauth_enabled 

185 and not self.oauth_providers 

186 ): 

187 raise ValueError( 

188 "oauth_providers must be configured when oauth_enabled=True" 

189 ) 

190 

191 if ( 

192 self.env in {"production", "staging"} 

193 and self.csrf_token_lifetime > self.idle_timeout 

194 ): 

195 raise ValueError("csrf_token_lifetime must not exceed idle_timeout") 

196 

197 return self