Coverage for src/kwai/api/v1/auth/endpoints/login.py: 84%

92 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2024-01-01 00:00 +0000

1"""Module that implements all APIs for login.""" 

2 

3from typing import Annotated 

4 

5import jwt 

6 

7from fastapi import ( 

8 APIRouter, 

9 Cookie, 

10 Depends, 

11 Form, 

12 Header, 

13 HTTPException, 

14 Request, 

15 status, 

16) 

17from fastapi.responses import Response 

18from fastapi.security import OAuth2PasswordRequestForm 

19from loguru import logger 

20 

21from kwai.api.dependencies import create_database, get_publisher 

22from kwai.api.v1.auth.cookies import create_cookies, delete_cookies 

23from kwai.core.db.database import Database 

24from kwai.core.db.uow import UnitOfWork 

25from kwai.core.domain.exceptions import UnprocessableException 

26from kwai.core.domain.value_objects.email_address import InvalidEmailException 

27from kwai.core.events.publisher import Publisher 

28from kwai.core.settings import Settings, get_settings 

29from kwai.modules.identity.authenticate_user import ( 

30 AuthenticateUser, 

31 AuthenticateUserCommand, 

32 AuthenticationException, 

33) 

34from kwai.modules.identity.exceptions import NotAllowedException 

35from kwai.modules.identity.logout import Logout, LogoutCommand 

36from kwai.modules.identity.recover_user import RecoverUser, RecoverUserCommand 

37from kwai.modules.identity.refresh_access_token import ( 

38 RefreshAccessToken, 

39 RefreshAccessTokenCommand, 

40) 

41from kwai.modules.identity.reset_password import ResetPassword, ResetPasswordCommand 

42from kwai.modules.identity.tokens.access_token_db_repository import ( 

43 AccessTokenDbRepository, 

44) 

45from kwai.modules.identity.tokens.log_user_login_db_service import LogUserLoginDbService 

46from kwai.modules.identity.tokens.refresh_token_db_repository import ( 

47 RefreshTokenDbRepository, 

48) 

49from kwai.modules.identity.tokens.refresh_token_repository import ( 

50 RefreshTokenNotFoundException, 

51) 

52from kwai.modules.identity.user_recoveries.user_recovery_db_repository import ( 

53 UserRecoveryDbRepository, 

54) 

55from kwai.modules.identity.user_recoveries.user_recovery_repository import ( 

56 UserRecoveryNotFoundException, 

57) 

58from kwai.modules.identity.users.user_account_db_repository import ( 

59 UserAccountDbRepository, 

60) 

61from kwai.modules.identity.users.user_account_repository import ( 

62 UserAccountNotFoundException, 

63) 

64 

65 

66router = APIRouter() 

67 

68 

69@router.post( 

70 "/login", 

71 summary="Create access and refresh token for a user.", 

72 responses={ 

73 200: {"description": "The user is logged in successfully."}, 

74 401: { 

75 "description": "The email is invalid, authentication failed or user is unknown." 

76 }, 

77 }, 

78) 

79async def login( 

80 request: Request, 

81 settings: Annotated[Settings, Depends(get_settings)], 

82 db: Annotated[Database, Depends(create_database)], 

83 form_data: Annotated[OAuth2PasswordRequestForm, Depends()], 

84 response: Response, 

85 x_forwarded_for: Annotated[str | None, Header()] = None, 

86 user_agent: Annotated[str | None, Header()] = "", 

87): 

88 """Login a user. 

89 

90 This request expects a form (application/x-www-form-urlencoded). The form 

91 must contain a `username` and `password` field. The username is 

92 the email address of the user. 

93 

94 On success, a cookie for the access token and the refresh token will be returned. 

95 """ 

96 command = AuthenticateUserCommand( 

97 username=form_data.username, 

98 password=form_data.password, 

99 access_token_expiry_minutes=settings.security.access_token_expires_in, 

100 refresh_token_expiry_minutes=settings.security.refresh_token_expires_in, 

101 ) 

102 

103 try: 

104 async with UnitOfWork(db, always_commit=True): 

105 refresh_token = await AuthenticateUser( 

106 UserAccountDbRepository(db), 

107 AccessTokenDbRepository(db), 

108 RefreshTokenDbRepository(db), 

109 LogUserLoginDbService( 

110 db, 

111 email=form_data.username, 

112 user_agent=user_agent, 

113 client_ip=request.client.host 

114 if x_forwarded_for is None 

115 else x_forwarded_for, 

116 ), 

117 ).execute(command) 

118 except InvalidEmailException as exc: 

119 raise HTTPException( 

120 status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email address" 

121 ) from exc 

122 except AuthenticationException as exc: 

123 raise HTTPException( 

124 status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc) 

125 ) from exc 

126 except UserAccountNotFoundException as exc: 

127 raise HTTPException( 

128 status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc) 

129 ) from exc 

130 

131 create_cookies(response, refresh_token, settings) 

132 response.status_code = status.HTTP_200_OK 

133 

134 return response 

135 

136 

137@router.post( 

138 "/logout", 

139 summary="Logout the current user", 

140 responses={200: {"description": "The user is logged out successfully."}}, 

141) 

142async def logout( 

143 settings: Annotated[Settings, Depends(get_settings)], 

144 db: Annotated[Database, Depends(create_database)], 

145 response: Response, 

146 refresh_token: Annotated[str | None, Cookie()] = None, 

147) -> None: 

148 """Log out the current user. 

149 

150 A user is logged out by revoking the refresh token. The associated access token 

151 will also be revoked. 

152 

153 This request expects a form (application/x-www-form-urlencoded). The form 

154 must contain a **refresh_token** field. 

155 

156 Even when a token could not be found, the cookies will be deleted. 

157 """ 

158 if refresh_token: 

159 decoded_refresh_token = jwt.decode( 

160 refresh_token, 

161 key=settings.security.jwt_refresh_secret, 

162 algorithms=[settings.security.jwt_algorithm], 

163 ) 

164 command = LogoutCommand(identifier=decoded_refresh_token["jti"]) 

165 try: 

166 async with UnitOfWork(db): 

167 await Logout( 

168 refresh_token_repository=RefreshTokenDbRepository(db), 

169 access_token_repository=AccessTokenDbRepository(db), 

170 ).execute(command) 

171 except RefreshTokenNotFoundException: 

172 pass 

173 

174 delete_cookies(response) 

175 response.status_code = status.HTTP_200_OK 

176 

177 

178@router.post( 

179 "/access_token", 

180 summary="Renew an access token using a refresh token.", 

181 responses={ 

182 200: {"description": "The access token is renewed."}, 

183 401: {"description": "The refresh token is expired."}, 

184 }, 

185) 

186async def renew_access_token( 

187 request: Request, 

188 settings: Annotated[Settings, Depends(get_settings)], 

189 db: Annotated[Database, Depends(create_database)], 

190 refresh_token: Annotated[str, Cookie()], 

191 response: Response, 

192 x_forwarded_for: Annotated[str | None, Header()] = None, 

193 user_agent: Annotated[str | None, Header()] = "", 

194): 

195 """Refresh the access token. 

196 

197 On success, a new access token / refresh token cookie will be sent. 

198 

199 When the refresh token is expired, the user needs to log in again. 

200 """ 

201 try: 

202 decoded_refresh_token = jwt.decode( 

203 refresh_token, 

204 key=settings.security.jwt_refresh_secret, 

205 algorithms=[settings.security.jwt_algorithm], 

206 ) 

207 except jwt.ExpiredSignatureError as exc: 

208 raise HTTPException( 

209 status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc) 

210 ) from exc 

211 

212 command = RefreshAccessTokenCommand( 

213 identifier=decoded_refresh_token["jti"], 

214 access_token_expiry_minutes=settings.security.access_token_expires_in, 

215 refresh_token_expiry_minutes=settings.security.refresh_token_expires_in, 

216 ) 

217 

218 try: 

219 async with UnitOfWork(db, always_commit=True): 

220 new_refresh_token = await RefreshAccessToken( 

221 RefreshTokenDbRepository(db), 

222 AccessTokenDbRepository(db), 

223 LogUserLoginDbService( 

224 db, 

225 email="", 

226 user_agent=user_agent, 

227 client_ip=request.client.host 

228 if x_forwarded_for is None 

229 else x_forwarded_for, 

230 ), 

231 ).execute(command) 

232 except AuthenticationException as exc: 

233 raise HTTPException( 

234 status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc) 

235 ) from exc 

236 

237 create_cookies(response, new_refresh_token, settings) 

238 response.status_code = status.HTTP_200_OK 

239 

240 

241@router.post( 

242 "/recover", 

243 summary="Initiate a password reset flow", 

244 responses={ 

245 200: {"description": "Ok."}, 

246 }, 

247) 

248async def recover_user( 

249 db: Annotated[Database, Depends(create_database)], 

250 publisher: Annotated[Publisher, Depends(get_publisher)], 

251 email: Annotated[str, Form()], 

252) -> None: 

253 """Start a recover password flow for the given email address. 

254 

255 A mail with a unique id will be sent using the message bus. 

256 

257 This request expects a form (application/x-www-form-urlencoded). The form 

258 must contain an **email** field. 

259 

260 !!! Note 

261 To avoid leaking information, this api will always respond with 200 

262 """ 

263 command = RecoverUserCommand(email=email) 

264 try: 

265 async with UnitOfWork(db): 

266 await RecoverUser( 

267 UserAccountDbRepository(db), UserRecoveryDbRepository(db), publisher 

268 ).execute(command) 

269 except UserAccountNotFoundException: 

270 logger.warning(f"Unknown email address used for a password recovery: {email}") 

271 except UnprocessableException as ex: 

272 logger.warning(f"User recovery could not be started: {ex}") 

273 

274 

275@router.post( 

276 "/reset", 

277 summary="Reset the password of a user.", 

278 responses={ # noqa B006 

279 200: {"description": "The password is reset successfully."}, 

280 403: {"description": "This request is forbidden."}, 

281 404: {"description": "The uniqued id of the recovery could not be found."}, 

282 422: {"description": "The user could not be found."}, 

283 }, 

284) 

285async def reset_password( 

286 uuid: Annotated[str, Form()], 

287 password: Annotated[str, Form()], 

288 db: Annotated[Database, Depends(create_database)], 

289): 

290 """Reset the password of the user. 

291 

292 Http code 200 on success, 404 when the unique id is invalid, 422 when the 

293 request can't be processed, 403 when the request is forbidden. 

294 

295 This request expects a form (application/x-www-form-urlencoded). The form 

296 must contain an **uuid** and **password** field. The unique id must be valid 

297 and is retrieved by [/api/v1/auth/recover][post_/recover]. 

298 """ 

299 command = ResetPasswordCommand(uuid=uuid, password=password) 

300 try: 

301 async with UnitOfWork(db): 

302 await ResetPassword( 

303 user_account_repo=UserAccountDbRepository(db), 

304 user_recovery_repo=UserRecoveryDbRepository(db), 

305 ).execute(command) 

306 except UserRecoveryNotFoundException as exc: 

307 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) from exc 

308 except UserAccountNotFoundException as exc: 

309 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) from exc 

310 except NotAllowedException as exc: 

311 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from exc