Coverage for webapp/login/views.py: 88%

114 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-25 22:09 +0000

1import os 

2 

3import flask 

4from canonicalwebteam.store_api.dashboard import Dashboard 

5from canonicalwebteam.store_api.publishergw import PublisherGW 

6from canonicalwebteam.store_api.devicegw import DeviceGW 

7 

8from django_openid_auth.teams import TeamsRequest, TeamsResponse 

9from flask_openid import OpenID 

10 

11from webapp import authentication 

12from webapp.decorators import login_required 

13from webapp.helpers import api_publisher_session, api_session 

14from webapp.api.exceptions import ApiResponseError 

15from webapp.extensions import csrf 

16from webapp.login.macaroon import MacaroonRequest, MacaroonResponse 

17from webapp.publisher.snaps import logic 

18from webapp.publisher.snaps.build_views import ( 

19 complete_pending_snap_authorization, 

20) 

21from webapp.publisher.snaps.unregister import ( 

22 complete_pending_snap_unregister, 

23 is_pending_snap_unregister_valid, 

24) 

25from canonicalwebteam.exceptions import StoreApiResponseErrorList 

26 

27login = flask.Blueprint( 

28 "login", __name__, template_folder="/templates", static_folder="/static" 

29) 

30 

31LOGIN_URL = os.getenv("LOGIN_URL", "https://login.ubuntu.com") 

32ENVIRONMENT = os.getenv("ENVIRONMENT", "devel") 

33 

34 

35# getter for ENVIRONMENT variable 

36# this allows the value to be mocked in tests 

37def get_environment(): 

38 return ENVIRONMENT 

39 

40 

41LP_CANONICAL_TEAM = "canonical" 

42 

43open_id = OpenID( 

44 store_factory=lambda: None, 

45 safe_roots=[], 

46 extension_responses=[MacaroonResponse, TeamsResponse], 

47) 

48 

49dashboard = Dashboard(api_session) 

50publisher_gateway = PublisherGW("snap", api_publisher_session) 

51device_gateway = DeviceGW("snap", api_session) 

52 

53 

54@login.route( 

55 "/login/unregister-snap/<authorization_id>", methods=["GET", "POST"] 

56) 

57@csrf.exempt 

58@login_required 

59@open_id.loginhandler 

60def authorize_snap_unregister(authorization_id): 

61 """ 

62 Obtain a fresh SSO discharge for a confirmed snap-name unregister. 

63 

64 Normal login exchanges and discards root/discharge macaroons to keep 

65 the session cookie small, but snap unregister still uses SCA's legacy 

66 authentication and cannot use the exchanged token. This round-trip 

67 obtains the required credentials only for the pending action, without 

68 replacing the user's login session. Completion validates the action, 

69 account and five-minute expiry before unregistering and discarding the 

70 temporary credentials. 

71 """ 

72 pending = flask.session.get("pending_snap_unregister") 

73 openid_error = flask.session.pop("openid_error", None) 

74 if ( 

75 not is_pending_snap_unregister_valid(pending, authorization_id) 

76 or openid_error 

77 or "root_macaroon" in pending 

78 ): 

79 flask.session.pop("pending_snap_unregister", None) 

80 flask.flash( 

81 "Unregister authorization expired, failed or was cancelled. " 

82 "Please try again.", 

83 "negative", 

84 ) 

85 return flask.redirect("/snaps") 

86 

87 try: 

88 root = authentication.request_macaroon() 

89 except ApiResponseError as api_error: 

90 flask.session.pop("pending_snap_unregister", None) 

91 return flask.abort(502, str(api_error)) 

92 

93 pending["root_macaroon"] = root 

94 flask.session.modified = True 

95 return open_id.try_login( 

96 LOGIN_URL, 

97 extensions=[ 

98 MacaroonRequest(caveat_id=authentication.get_caveat_id(root)) 

99 ], 

100 ) 

101 

102 

103@login.route("/login/snap-build-authorization", methods=["GET", "POST"]) 

104@csrf.exempt 

105@open_id.loginhandler 

106@login_required 

107def authorize_snap_build(): 

108 """ 

109 Kick off a discharge round-trip for a snap's Launchpad build/upload 

110 macaroon (see 

111 webapp/publisher/snaps/build_views.py:post_snap_builds). 

112 

113 The macaroon returned by the store's package-upload-macaroon endpoint 

114 carries an SSO third-party caveat that is unique to that macaroon; it 

115 can only be discharged by redirecting the user through login.ubuntu.com 

116 for *that specific* caveat_id. A discharge obtained anywhere else 

117 (e.g. at the user's original login) cannot be reused here. 

118 """ 

119 pending = flask.session.get("pending_snap_authorization") 

120 

121 if not pending: 

122 flask.flash("Nothing to authorize.", "negative") 

123 return flask.redirect("/") 

124 

125 macaroon_request = MacaroonRequest( 

126 caveat_id=authentication.get_caveat_id(pending["root_macaroon"]) 

127 ) 

128 

129 return open_id.try_login( 

130 LOGIN_URL, 

131 extensions=[macaroon_request], 

132 ) 

133 

134 

135@login.route("/login", methods=["GET", "POST"]) 

136@csrf.exempt 

137@open_id.loginhandler 

138def login_handler(): 

139 if authentication.is_authenticated(flask.session): 

140 return flask.redirect(open_id.get_next_url()) 

141 

142 try: 

143 root = authentication.request_macaroon() 

144 except ApiResponseError as api_response_error: 

145 if api_response_error.status_code == 401: 

146 return flask.redirect(flask.url_for(".logout")) 

147 else: 

148 return flask.abort(502, str(api_response_error)) 

149 

150 openid_macaroon = MacaroonRequest( 

151 caveat_id=authentication.get_caveat_id(root) 

152 ) 

153 flask.session["macaroon_root"] = root 

154 

155 lp_teams = TeamsRequest(query_membership=[LP_CANONICAL_TEAM]) 

156 

157 return open_id.try_login( 

158 LOGIN_URL, 

159 ask_for=["email", "nickname", "image"], 

160 ask_for_optional=["fullname"], 

161 extensions=[openid_macaroon, lp_teams], 

162 ) 

163 

164 

165@open_id.after_login 

166def after_login(resp): 

167 # Unregister uses SSO to discharge temporary credentials, not to log in. 

168 # Complete the confirmed action before normal login handling can replace 

169 # the existing publisher session. 

170 if flask.request.endpoint == "login.authorize_snap_unregister": 

171 return complete_pending_snap_unregister( 

172 resp, flask.request.view_args["authorization_id"] 

173 ) 

174 

175 # Other callbacks either log in or discharge a snap's Launchpad upload 

176 # macaroon (see authorize_snap_build). Handle build authorization first, 

177 # since none of the account/session logic below applies to it. 

178 pending = flask.session.pop("pending_snap_authorization", None) 

179 if pending: 

180 discharge_macaroon = resp.extensions["macaroon"].discharge 

181 return complete_pending_snap_authorization(pending, discharge_macaroon) 

182 

183 discharge_macaroon = resp.extensions["macaroon"].discharge 

184 flask.session["macaroon_discharge"] = discharge_macaroon 

185 

186 if not resp.nickname: 

187 return flask.redirect(LOGIN_URL) 

188 

189 # Exchange root + discharge for a single dashboard token. 

190 # Both keys are in the session here, so exchange_dashboard_macaroons 

191 # can read them directly. We then drop them to keep the cookie small. 

192 try: 

193 flask.session["macaroon_exchanged"] = ( 

194 publisher_gateway.exchange_dashboard_macaroons(flask.session) 

195 ) 

196 except StoreApiResponseErrorList as api_error: 

197 # A brand-new publisher who has never accepted the developer Terms & 

198 # Conditions has no publisher account yet, so the macaroon exchange 

199 # fails with "account-not-found". Rather than returning a 404, keep 

200 # the dashboard (root + discharge) macaroons, establish a minimal 

201 # authenticated session and guide the user to the agreement page. 

202 # Accepting the agreement creates the account, after which the 

203 # exchange succeeds on the next request. See issue #5788. 

204 if any( 

205 error.get("code") == "account-not-found" 

206 for error in api_error.errors 

207 ): 

208 flask.session["publisher"] = { 

209 "identity_url": resp.identity_url, 

210 "nickname": resp.nickname, 

211 "fullname": resp.fullname, 

212 "image": resp.image, 

213 "email": resp.email, 

214 } 

215 return flask.redirect(flask.url_for("account.get_agreement")) 

216 raise 

217 

218 flask.session.pop("macaroon_root", None) 

219 flask.session.pop("macaroon_discharge", None) 

220 

221 flask.session["publisher"] = { 

222 "identity_url": resp.identity_url, 

223 "nickname": resp.nickname, 

224 "fullname": resp.fullname, 

225 "image": resp.image, 

226 "email": resp.email, 

227 } 

228 

229 account = dashboard.get_account(flask.session) 

230 validation_sets = dashboard.get_validation_sets(flask.session) 

231 

232 if account: 

233 is_canonical = LP_CANONICAL_TEAM in resp.extensions["lp"].is_member 

234 

235 # in environments other than production, for testing purposes, 

236 # we detect if the user is Canonical by checking 

237 # if the email ends with @canonical.com 

238 if (not is_canonical) and get_environment() != "production": 

239 is_canonical = account["email"] and account["email"].endswith( 

240 "@canonical.com" 

241 ) 

242 

243 flask.session["publisher"] = { 

244 "identity_url": resp.identity_url, 

245 "nickname": account["username"], 

246 "fullname": account["displayname"], 

247 "image": resp.image, 

248 "email": account["email"], 

249 "is_canonical": is_canonical, 

250 } 

251 

252 if logic.get_stores( 

253 account["stores"], roles=["admin", "review", "view"] 

254 ): 

255 flask.session["publisher"]["has_stores"] = ( 

256 len(dashboard.get_stores(flask.session)) > 0 

257 ) 

258 

259 flask.session["publisher"]["has_validation_sets"] = ( 

260 validation_sets is not None 

261 and len(validation_sets["assertions"]) > 0 

262 ) 

263 

264 response = flask.make_response( 

265 flask.redirect( 

266 open_id.get_next_url(), 

267 302, 

268 ), 

269 ) 

270 return response 

271 

272 

273@login.route("/login-beta", methods=["GET"]) 

274def login_beta(): 

275 return flask.redirect(flask.url_for(".login_handler")) 

276 

277 

278@login.route("/logout") 

279def logout(): 

280 authentication.empty_session(flask.session) 

281 

282 return flask.redirect("/")