Coverage for gws-app/gws/plugin/account/account_action.py: 0%

82 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Action for the account onboarding procedure.""" 

2 

3from typing import Optional, cast 

4 

5import gws 

6import gws.config.util 

7import gws.base.action 

8import gws.lib.mime 

9 

10from . import core, helper 

11 

12 

13@gws.ext.config.action('account') 

14class Config(gws.base.action.Config): 

15 """Account management for end users, including onboarding.""" 

16 pass 

17 

18 

19@gws.ext.props.action('account') 

20class Props(gws.base.action.Props): 

21 pass 

22 

23 

24class MfaProps(gws.Data): 

25 """MFA method offered to the user during onboarding.""" 

26 

27 index: int 

28 """Index of the method in the helper's ``mfa`` list, starting with 1.""" 

29 title: str 

30 """Title of the method.""" 

31 qrCode: str 

32 """QR code of the key URI as a data URL, empty if the method has no key URI.""" 

33 

34 

35class OnboardingStartRequest(gws.Request): 

36 """Request to start the onboarding.""" 

37 

38 tc: str 

39 """Temporary code from the onboarding link.""" 

40 

41 

42class OnboardingStartResponse(gws.Response): 

43 """Response to the onboarding start.""" 

44 

45 tc: str 

46 """New temporary code for the next step.""" 

47 

48 

49class OnboardingSavePasswordRequest(gws.Request): 

50 """Request to set the password during onboarding.""" 

51 

52 tc: str 

53 """Temporary code from the previous step.""" 

54 email: str 

55 """Email address, must match the account's email.""" 

56 password1: str 

57 """New password.""" 

58 password2: str 

59 """New password, repeated.""" 

60 

61 

62class OnboardingSavePasswordResponse(gws.Response): 

63 """Response to setting the password.""" 

64 

65 tc: str 

66 """New temporary code for the next step, if the onboarding is not complete.""" 

67 ok: bool 

68 """``False`` if the email does not match or the password is invalid.""" 

69 complete: bool 

70 """``True`` if the account is active, ``False`` if an MFA method must be selected.""" 

71 completionUrl: str 

72 """URL to go to when the onboarding is complete.""" 

73 mfaList: list[MfaProps] 

74 """MFA methods to choose from.""" 

75 

76 

77class OnboardingSaveMfaRequest(gws.Request): 

78 """Request to select an MFA method during onboarding.""" 

79 

80 tc: str 

81 """Temporary code from the previous step.""" 

82 mfaIndex: Optional[int] 

83 """Index of the selected method.""" 

84 

85 

86class OnboardingSaveMfaResponse(gws.Response): 

87 """Response to selecting an MFA method.""" 

88 

89 complete: bool 

90 """``True`` when the onboarding is complete.""" 

91 completionUrl: str 

92 """URL to go to when the onboarding is complete.""" 

93 

94 

95@gws.ext.object.action('account') 

96class Object(gws.base.action.Object): 

97 """Account action, provides the onboarding API for the client. 

98 

99 The onboarding steps are: start, set the password, select an MFA method (only if MFA methods are configured). 

100 Each step requires the temporary code returned by the previous step, the first one the code from the onboarding link. 

101 """ 

102 

103 h: helper.Object 

104 """The account helper.""" 

105 

106 def configure(self): 

107 self.h = cast(helper.Object, self.root.app.helper('account')) 

108 

109 @gws.ext.command.api('accountOnboardingStart') 

110 def onboarding_start(self, req: gws.WebRequester, p: OnboardingStartRequest) -> OnboardingStartResponse: 

111 """Start the account onboarding.""" 

112 

113 account = self.get_account_by_tc(p.tc, core.Category.onboarding, core.Status.new) 

114 self.h.set_status(account, core.Status.onboarding) 

115 return OnboardingStartResponse( 

116 tc=self.h.generate_tc(account, core.Category.onboarding) 

117 ) 

118 

119 @gws.ext.command.api('accountOnboardingSavePassword') 

120 def onboarding_save_password(self, req: gws.WebRequester, p: OnboardingSavePasswordRequest) -> OnboardingSavePasswordResponse: 

121 """Set the password of the account.""" 

122 

123 account = self.get_account_by_tc(p.tc, core.Category.onboarding, core.Status.onboarding) 

124 

125 p1 = p.password1 

126 p2 = p.password2 

127 

128 if account.get('email') != p.email or p1 != p2 or not self.h.validate_password(p1): 

129 return OnboardingSavePasswordResponse( 

130 ok=False, 

131 tc=self.h.generate_tc(account, core.Category.onboarding), 

132 ) 

133 

134 self.h.set_password(account, p1) 

135 

136 mfa = self.h.mfa_options(account) 

137 if mfa: 

138 mfa_secret = self.h.generate_mfa_secret(account) 

139 return OnboardingSavePasswordResponse( 

140 ok=True, 

141 complete=False, 

142 mfaList=self.mfa_props(account, mfa_secret), 

143 tc=self.h.generate_tc(account, core.Category.onboarding), 

144 ) 

145 

146 self.h.set_status(account, core.Status.active) 

147 self.h.clear_tc(account) 

148 return OnboardingSavePasswordResponse( 

149 ok=True, 

150 complete=True, 

151 completionUrl=self.h.onboardingCompletionUrl, 

152 ) 

153 

154 @gws.ext.command.api('accountOnboardingSaveMfa') 

155 def onboarding_save_mfa(self, req: gws.WebRequester, p: OnboardingSaveMfaRequest) -> OnboardingSaveMfaResponse: 

156 """Save the selected MFA method and activate the account.""" 

157 

158 account = self.get_account_by_tc(p.tc, core.Category.onboarding, core.Status.onboarding) 

159 

160 self.h.set_mfa(account, p.mfaIndex) 

161 self.h.set_status(account, core.Status.active) 

162 self.h.clear_tc(account) 

163 

164 return OnboardingSaveMfaResponse( 

165 complete=True, 

166 completionUrl=self.h.onboardingCompletionUrl, 

167 ) 

168 

169 ## 

170 

171 def get_account_by_tc(self, tc, category, expected_status): 

172 """Find an account by a temporary code. 

173 

174 The code is invalidated. 

175 

176 Args: 

177 tc: Temporary code. 

178 category: Expected code category. 

179 expected_status: Expected account status. 

180 

181 Returns: 

182 The account record. 

183 

184 Raises: 

185 gws.ForbiddenError: If no valid account is found. 

186 """ 

187 

188 try: 

189 account = self.h.get_account_by_tc(tc, category, expected_status) 

190 except helper.Error as exc: 

191 raise gws.ForbiddenError() from exc 

192 

193 if not account: 

194 raise gws.ForbiddenError(f'account: {tc=} not found') 

195 

196 return account 

197 

198 def mfa_props(self, account: dict, mfa_secret): 

199 """Create props for the MFA methods available to an account. 

200 

201 Args: 

202 account: Account record. 

203 mfa_secret: MFA secret for the QR codes. 

204 

205 Returns: 

206 A list of ``MfaProps``. 

207 """ 

208 

209 ps = [] 

210 

211 for mo in self.h.mfa_options(account): 

212 ps.append(MfaProps( 

213 index=mo.index, 

214 title=mo.title, 

215 qrCode=self.h.qr_code_for_mfa(account, mo, mfa_secret) 

216 )) 

217 

218 return ps