Coverage for webapp/login/views.py: 88%
92 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 22:23 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 22:23 +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 canonicalwebteam.exceptions import StoreApiResponseErrorList
23login = flask.Blueprint(
24 "login", __name__, template_folder="/templates", static_folder="/static"
25)
27LOGIN_URL = os.getenv("LOGIN_URL", "https://login.ubuntu.com")
28ENVIRONMENT = os.getenv("ENVIRONMENT", "devel")
31# getter for ENVIRONMENT variable
32# this allows the value to be mocked in tests
33def get_environment():
34 return ENVIRONMENT
37LP_CANONICAL_TEAM = "canonical"
39open_id = OpenID(
40 store_factory=lambda: None,
41 safe_roots=[],
42 extension_responses=[MacaroonResponse, TeamsResponse],
43)
45dashboard = Dashboard(api_session)
46publisher_gateway = PublisherGW(api_publisher_session)
47device_gateway = DeviceGW("snap", api_session)
50@login.route("/login/snap-build-authorization", methods=["GET", "POST"])
51@csrf.exempt
52@open_id.loginhandler
53@login_required
54def authorize_snap_build():
55 """
56 Kick off a discharge round-trip for a snap's Launchpad build/upload
57 macaroon (see
58 webapp/publisher/snaps/build_views.py:post_snap_builds).
60 The macaroon returned by the store's package-upload-macaroon endpoint
61 carries an SSO third-party caveat that is unique to that macaroon; it
62 can only be discharged by redirecting the user through login.ubuntu.com
63 for *that specific* caveat_id. A discharge obtained anywhere else
64 (e.g. at the user's original login) cannot be reused here.
65 """
66 pending = flask.session.get("pending_snap_authorization")
68 if not pending:
69 flask.flash("Nothing to authorize.", "negative")
70 return flask.redirect("/")
72 macaroon_request = MacaroonRequest(
73 caveat_id=authentication.get_caveat_id(pending["root_macaroon"])
74 )
76 return open_id.try_login(
77 LOGIN_URL,
78 extensions=[macaroon_request],
79 )
82@login.route("/login", methods=["GET", "POST"])
83@csrf.exempt
84@open_id.loginhandler
85def login_handler():
86 if authentication.is_authenticated(flask.session):
87 return flask.redirect(open_id.get_next_url())
89 try:
90 root = authentication.request_macaroon()
91 except ApiResponseError as api_response_error:
92 if api_response_error.status_code == 401:
93 return flask.redirect(flask.url_for(".logout"))
94 else:
95 return flask.abort(502, str(api_response_error))
97 openid_macaroon = MacaroonRequest(
98 caveat_id=authentication.get_caveat_id(root)
99 )
100 flask.session["macaroon_root"] = root
102 lp_teams = TeamsRequest(query_membership=[LP_CANONICAL_TEAM])
104 return open_id.try_login(
105 LOGIN_URL,
106 ask_for=["email", "nickname", "image"],
107 ask_for_optional=["fullname"],
108 extensions=[openid_macaroon, lp_teams],
109 )
112@open_id.after_login
113def after_login(resp):
114 # This same OpenID round-trip is reused for two purposes: a normal
115 # user login, and (see authorize_snap_build above) discharging a
116 # snap's Launchpad upload macaroon. Handle the latter first and bail
117 # out early, since none of the account/session logic below applies.
118 pending = flask.session.pop("pending_snap_authorization", None)
119 if pending:
120 discharge_macaroon = resp.extensions["macaroon"].discharge
121 return complete_pending_snap_authorization(pending, discharge_macaroon)
123 discharge_macaroon = resp.extensions["macaroon"].discharge
124 flask.session["macaroon_discharge"] = discharge_macaroon
126 if not resp.nickname:
127 return flask.redirect(LOGIN_URL)
129 # Exchange root + discharge for a single dashboard token.
130 # Both keys are in the session here, so exchange_dashboard_macaroons
131 # can read them directly. We then drop them to keep the cookie small.
132 try:
133 flask.session["macaroon_exchanged"] = (
134 publisher_gateway.exchange_dashboard_macaroons(flask.session)
135 )
136 except StoreApiResponseErrorList as api_error:
137 # A brand-new publisher who has never accepted the developer Terms &
138 # Conditions has no publisher account yet, so the macaroon exchange
139 # fails with "account-not-found". Rather than returning a 404, keep
140 # the dashboard (root + discharge) macaroons, establish a minimal
141 # authenticated session and guide the user to the agreement page.
142 # Accepting the agreement creates the account, after which the
143 # exchange succeeds on the next request. See issue #5788.
144 if any(
145 error.get("code") == "account-not-found"
146 for error in api_error.errors
147 ):
148 flask.session["publisher"] = {
149 "identity_url": resp.identity_url,
150 "nickname": resp.nickname,
151 "fullname": resp.fullname,
152 "image": resp.image,
153 "email": resp.email,
154 }
155 return flask.redirect(flask.url_for("account.get_agreement"))
156 raise
158 flask.session.pop("macaroon_root", None)
159 flask.session.pop("macaroon_discharge", None)
161 flask.session["publisher"] = {
162 "identity_url": resp.identity_url,
163 "nickname": resp.nickname,
164 "fullname": resp.fullname,
165 "image": resp.image,
166 "email": resp.email,
167 }
169 account = dashboard.get_account(flask.session)
170 validation_sets = dashboard.get_validation_sets(flask.session)
172 if account:
173 is_canonical = LP_CANONICAL_TEAM in resp.extensions["lp"].is_member
175 # in environments other than production, for testing purposes,
176 # we detect if the user is Canonical by checking
177 # if the email ends with @canonical.com
178 if (not is_canonical) and get_environment() != "production":
179 is_canonical = account["email"] and account["email"].endswith(
180 "@canonical.com"
181 )
183 flask.session["publisher"] = {
184 "identity_url": resp.identity_url,
185 "nickname": account["username"],
186 "fullname": account["displayname"],
187 "image": resp.image,
188 "email": account["email"],
189 "is_canonical": is_canonical,
190 }
192 if logic.get_stores(
193 account["stores"], roles=["admin", "review", "view"]
194 ):
195 flask.session["publisher"]["has_stores"] = (
196 len(dashboard.get_stores(flask.session)) > 0
197 )
199 flask.session["publisher"]["has_validation_sets"] = (
200 validation_sets is not None
201 and len(validation_sets["assertions"]) > 0
202 )
204 response = flask.make_response(
205 flask.redirect(
206 open_id.get_next_url(),
207 302,
208 ),
209 )
210 return response
213@login.route("/login-beta", methods=["GET"])
214def login_beta():
215 return flask.redirect(flask.url_for(".login_handler"))
218@login.route("/logout")
219def logout():
220 authentication.empty_session(flask.session)
222 return flask.redirect("/")