Coverage for src/lexigram/admin/auth/services/mfa_service.py: 0%

53 statements  

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

1"""TOTP two-factor authentication service for the admin panel. 

2 

3Owns the 2FA lifecycle: secret generation, QR provisioning, code 

4verification, and disable. TOTP math is delegated to lexigram-auth's 

5``authn.mfa`` primitives (RFC 6238 single source of truth); this module 

6keeps the admin store wiring and the QR SVG rendering. Depends only on 

7the MFA store protocol and (optionally) the audit log service, so it is 

8trivially testable without a database. 

9""" 

10 

11from __future__ import annotations 

12 

13import segno 

14 

15from lexigram.admin.auth.errors import ( 

16 AdminAuthError, 

17 MfaNotEnabledError, 

18 MfaVerificationFailedError, 

19) 

20from lexigram.admin.auth.protocols import ( 

21 AdminAuditLogServiceProtocol, 

22 AdminMfaStoreProtocol, 

23) 

24from lexigram.admin.auth.types import AdminSecurityEventType 

25from lexigram.admin.config import AdminMfaConfig 

26from lexigram.auth.authn.mfa import ( 

27 generate_totp_secret, 

28 get_provisioning_uri, 

29 verify_totp, 

30) 

31from lexigram.logging import get_logger 

32from lexigram.result import Err, Ok, Result 

33 

34logger = get_logger(__name__) 

35 

36 

37class AdminMfaService: 

38 """TOTP 2FA lifecycle: setup, verify, disable. 

39 

40 Args: 

41 config: MFA configuration (issuer, skew, enabled flag). 

42 store: Persistence for per-user TOTP secrets. 

43 audit_service: Optional security-event recorder; ``None`` skips 

44 audit logging (e.g. in tests). 

45 """ 

46 

47 def __init__( 

48 self, 

49 config: AdminMfaConfig, 

50 store: AdminMfaStoreProtocol, 

51 audit_service: AdminAuditLogServiceProtocol | None = None, 

52 ) -> None: 

53 self._config = config 

54 self._store = store 

55 self._audit_service = audit_service 

56 

57 # ------------------------------------------------------------------ 

58 # Public API 

59 # ------------------------------------------------------------------ 

60 

61 def get_factor(self) -> str: 

62 """Return the configured second factor (``"totp"`` or ``"email"``).""" 

63 return self._config.factor 

64 

65 async def is_enabled(self, user_id: str) -> bool: 

66 """Return True when 2FA is enabled for the user.""" 

67 return await self._store.is_enabled(user_id) 

68 

69 async def start_setup( 

70 self, user_id: str, email: str 

71 ) -> Result[tuple[str, str, str], AdminAuthError]: 

72 """Generate a new TOTP secret, provisioning URI, and QR SVG. 

73 

74 Nothing is persisted here — the secret travels via the caller 

75 (session) and is committed by ``confirm_setup``. 

76 

77 Args: 

78 user_id: Admin user UUID. 

79 email: Admin user email (embedded in the provisioning URI). 

80 

81 Returns: 

82 ``Ok((secret, otpauth_uri, svg))`` on success. 

83 ``Err(MfaNotEnabledError)`` when 2FA is disabled in config. 

84 """ 

85 if not self._config.enabled: 

86 return Err( 

87 MfaNotEnabledError( 

88 "Two-factor authentication is disabled for this panel." 

89 ) 

90 ) 

91 secret = generate_totp_secret() 

92 uri = get_provisioning_uri(secret, username=email, issuer=self._config.issuer) 

93 svg = segno.make(uri).svg_inline(scale=4) 

94 return Ok((secret, uri, svg)) 

95 

96 async def confirm_setup( 

97 self, user_id: str, secret: str, code: str 

98 ) -> Result[None, AdminAuthError]: 

99 """Validate a code against a new secret and persist it. 

100 

101 Args: 

102 user_id: Admin user UUID. 

103 secret: TOTP secret (from ``start_setup``). 

104 code: TOTP code to validate. 

105 

106 Returns: 

107 ``Ok(None)`` when the code is valid and the secret is stored. 

108 ``Err(MfaVerificationFailedError)`` when the code is invalid. 

109 ``Err(MfaNotEnabledError)`` when 2FA is disabled in config. 

110 """ 

111 if not self._config.enabled: 

112 return Err( 

113 MfaNotEnabledError( 

114 "Two-factor authentication is disabled for this panel." 

115 ) 

116 ) 

117 if not verify_totp(secret, code, window=self._config.skew): 

118 return Err(MfaVerificationFailedError("Invalid verification code.")) 

119 await self._store.save_secret(user_id, secret) 

120 if self._audit_service is not None: 

121 await self._audit_service.log_event( 

122 event_type=AdminSecurityEventType.MFA_ENABLED, 

123 ip_address="", 

124 user_agent="", 

125 success=True, 

126 admin_user_id=user_id, 

127 metadata={}, 

128 ) 

129 logger.info("admin_mfa_enabled", user_id=user_id) 

130 return Ok(None) 

131 

132 async def verify_code( 

133 self, user_id: str, code: str 

134 ) -> Result[bool, AdminAuthError]: 

135 """Validate a TOTP code for a user without persisting anything. 

136 

137 Args: 

138 user_id: Admin user UUID. 

139 code: TOTP code to validate. 

140 

141 Returns: 

142 ``Ok(True)`` for a valid code, ``Ok(False)`` otherwise. 

143 ``Err(MfaNotEnabledError)`` when the user has no stored secret. 

144 """ 

145 secret = await self._store.get_secret(user_id) 

146 if secret is None: 

147 return Err( 

148 MfaNotEnabledError( 

149 "Two-factor authentication is not enabled for this account." 

150 ) 

151 ) 

152 return Ok(verify_totp(secret, code, window=self._config.skew)) 

153 

154 async def disable(self, user_id: str, code: str) -> Result[bool, AdminAuthError]: 

155 """Disable 2FA for a user (requires a valid current code). 

156 

157 Args: 

158 user_id: Admin user UUID. 

159 code: Current TOTP code proving possession of the secret. 

160 

161 Returns: 

162 ``Ok(True)`` when disabled. 

163 ``Err(MfaNotEnabledError)`` when 2FA is not enabled. 

164 ``Err(MfaVerificationFailedError)`` when the code is invalid. 

165 """ 

166 result = await self.verify_code(user_id, code) 

167 if result.is_err(): 

168 return result 

169 if not result.unwrap(): 

170 return Err(MfaVerificationFailedError("Invalid verification code.")) 

171 await self._store.disable(user_id) 

172 if self._audit_service is not None: 

173 await self._audit_service.log_event( 

174 event_type=AdminSecurityEventType.MFA_DISABLED, 

175 ip_address="", 

176 user_agent="", 

177 success=True, 

178 admin_user_id=user_id, 

179 metadata={}, 

180 ) 

181 logger.info("admin_mfa_disabled", user_id=user_id) 

182 return Ok(True) 

183 

184 

185__all__ = ["AdminMfaService"]