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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-25 22:09 +0000
1import os
3import flask
4from canonicalwebteam.store_api.dashboard import Dashboard
5from canonicalwebteam.store_api.publishergw import PublisherGW
6from canonicalwebteam.store_api.devicegw import DeviceGW
8from django_openid_auth.teams import TeamsRequest, TeamsResponse
9from flask_openid import OpenID
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
27login = flask.Blueprint(
28 "login", __name__, template_folder="/templates", static_folder="/static"
29)
31LOGIN_URL = os.getenv("LOGIN_URL", "https://login.ubuntu.com")
32ENVIRONMENT = os.getenv("ENVIRONMENT", "devel")
35# getter for ENVIRONMENT variable
36# this allows the value to be mocked in tests
37def get_environment():
38 return ENVIRONMENT
41LP_CANONICAL_TEAM = "canonical"
43open_id = OpenID(
44 store_factory=lambda: None,
45 safe_roots=[],
46 extension_responses=[MacaroonResponse, TeamsResponse],
47)
49dashboard = Dashboard(api_session)
50publisher_gateway = PublisherGW("snap", api_publisher_session)
51device_gateway = DeviceGW("snap", api_session)
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.
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")
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))
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 )
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).
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")
121 if not pending:
122 flask.flash("Nothing to authorize.", "negative")
123 return flask.redirect("/")
125 macaroon_request = MacaroonRequest(
126 caveat_id=authentication.get_caveat_id(pending["root_macaroon"])
127 )
129 return open_id.try_login(
130 LOGIN_URL,
131 extensions=[macaroon_request],
132 )
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())
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))
150 openid_macaroon = MacaroonRequest(
151 caveat_id=authentication.get_caveat_id(root)
152 )
153 flask.session["macaroon_root"] = root
155 lp_teams = TeamsRequest(query_membership=[LP_CANONICAL_TEAM])
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 )
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 )
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)
183 discharge_macaroon = resp.extensions["macaroon"].discharge
184 flask.session["macaroon_discharge"] = discharge_macaroon
186 if not resp.nickname:
187 return flask.redirect(LOGIN_URL)
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
218 flask.session.pop("macaroon_root", None)
219 flask.session.pop("macaroon_discharge", None)
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 }
229 account = dashboard.get_account(flask.session)
230 validation_sets = dashboard.get_validation_sets(flask.session)
232 if account:
233 is_canonical = LP_CANONICAL_TEAM in resp.extensions["lp"].is_member
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 )
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 }
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 )
259 flask.session["publisher"]["has_validation_sets"] = (
260 validation_sets is not None
261 and len(validation_sets["assertions"]) > 0
262 )
264 response = flask.make_response(
265 flask.redirect(
266 open_id.get_next_url(),
267 302,
268 ),
269 )
270 return response
273@login.route("/login-beta", methods=["GET"])
274def login_beta():
275 return flask.redirect(flask.url_for(".login_handler"))
278@login.route("/logout")
279def logout():
280 authentication.empty_session(flask.session)
282 return flask.redirect("/")