diff --git a/cypress/integration/api-tokens.spec.js b/cypress/integration/api-tokens.spec.js --- a/cypress/integration/api-tokens.spec.js +++ b/cypress/integration/api-tokens.spec.js @@ -8,7 +8,7 @@ describe('Test API tokens UI', function() { it('should ask for user to login', function() { - cy.visit(this.Urls.api_tokens(), {failOnStatusCode: false}); + cy.visit(`${this.Urls.oidc_profile()}#tokens`, {failOnStatusCode: false}); cy.location().should(loc => { expect(loc.pathname).to.eq(this.Urls.oidc_login()); }); @@ -29,7 +29,7 @@ // the tested UI should not be accessible for standard Django users // but we need a user logged in for testing it cy.adminLogin(); - cy.visit(Urls.api_tokens()); + cy.visit(`${Urls.oidc_profile()}#tokens`); } function generateToken(Urls, status, tokenValue = '') { @@ -104,7 +104,7 @@ }).as('getTokenRequest'); cy.contains('Display token') - .click(); + .click({force: true}); cy.get('.modal-dialog') .should('be.visible'); @@ -116,13 +116,13 @@ .should('be.disabled'); cy.get('#swh-user-password') - .type('secret'); + .type('secret', {force: true}); cy.get('#swh-user-password-submit') .should('be.enabled'); cy.get('#swh-user-password-submit') - .click(); + .click({force: true}); cy.wait('@getTokenRequest'); @@ -162,7 +162,7 @@ }).as('revokeTokenRequest'); cy.contains('Revoke token') - .click(); + .click({force: true}); cy.get('.modal-dialog') .should('be.visible'); @@ -174,13 +174,13 @@ .should('be.disabled'); cy.get('#swh-user-password') - .type('secret'); + .type('secret', {force: true}); cy.get('#swh-user-password-submit') .should('be.enabled'); cy.get('#swh-user-password-submit') - .click(); + .click({force: true}); cy.wait('@revokeTokenRequest'); diff --git a/swh/web/api/urls.py b/swh/web/api/urls.py --- a/swh/web/api/urls.py +++ b/swh/web/api/urls.py @@ -3,10 +3,6 @@ # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information -from django.conf.urls import url -from django.contrib.auth.decorators import login_required -from django.shortcuts import render - from swh.web.api.apiurls import APIUrls import swh.web.api.views.content # noqa import swh.web.api.views.directory # noqa @@ -21,11 +17,4 @@ import swh.web.api.views.stat # noqa import swh.web.api.views.vault # noqa - -@login_required(login_url="/oidc/login/", redirect_field_name="next_path") -def _tokens_view(request): - return render(request, "api/tokens.html") - - urlpatterns = APIUrls.get_url_patterns() -urlpatterns.append(url(r"^tokens/$", _tokens_view, name="api-tokens")) diff --git a/swh/web/assets/src/bundles/auth/index.js b/swh/web/assets/src/bundles/auth/index.js --- a/swh/web/assets/src/bundles/auth/index.js +++ b/swh/web/assets/src/bundles/auth/index.js @@ -5,7 +5,7 @@ * See top-level LICENSE file for more information */ -import {handleFetchError, csrfPost} from 'utils/functions'; +import {handleFetchError, csrfPost, removeUrlFragment} from 'utils/functions'; import './auth.css'; let apiTokensTable; @@ -169,7 +169,7 @@ }); } -export function initApiTokensPage() { +export function initProfilePage() { $(document).ready(() => { apiTokensTable = $('#swh-bearer-tokens-table') .on('error.dt', (e, settings, techNote, message) => { @@ -212,5 +212,15 @@ scrollY: '50vh', scrollCollapse: true }); + $('#swh-oidc-profile-tokens-tab').on('shown.bs.tab', () => { + apiTokensTable.draw(); + window.location.hash = '#tokens'; + }); + $('#swh-oidc-profile-account-tab').on('shown.bs.tab', () => { + removeUrlFragment(); + }); + if (window.location.hash === '#tokens') { + $('.nav-tabs a[href="#swh-oidc-profile-tokens"]').tab('show'); + } }); } diff --git a/swh/web/auth/views.py b/swh/web/auth/views.py --- a/swh/web/auth/views.py +++ b/swh/web/auth/views.py @@ -13,6 +13,7 @@ from django.conf.urls import url from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.decorators import login_required from django.core.cache import cache from django.core.paginator import Paginator from django.http import HttpRequest @@ -23,6 +24,7 @@ HttpResponseServerError, JsonResponse, ) +from django.shortcuts import render from django.views.decorators.http import require_http_methods from swh.web.auth.models import OIDCUser, OIDCUserOfflineTokens @@ -216,6 +218,11 @@ return HttpResponse(status=401) +@login_required(login_url="/oidc/login/", redirect_field_name="next_path") +def _oidc_profile_view(request: HttpRequest) -> HttpResponse: + return render(request, "auth/profile.html") + + urlpatterns = [ url(r"^oidc/login/$", oidc_login, name="oidc-login"), url(r"^oidc/login-complete/$", oidc_login_complete, name="oidc-login-complete"), @@ -240,4 +247,5 @@ oidc_revoke_bearer_tokens, name="oidc-revoke-bearer-tokens", ), + url(r"^oidc/profile/$", _oidc_profile_view, name="oidc-profile",), ] diff --git a/swh/web/common/utils.py b/swh/web/common/utils.py --- a/swh/web/common/utils.py +++ b/swh/web/common/utils.py @@ -260,6 +260,7 @@ "swh_client_config": config["client_config"], "oidc_enabled": bool(config["keycloak"]["server_url"]), "browsers_supported_image_mimes": browsers_supported_image_mimes, + "keycloak": config["keycloak"], } diff --git a/swh/web/templates/api/tokens.html b/swh/web/templates/api/tokens.html deleted file mode 100644 --- a/swh/web/templates/api/tokens.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "layout.html" %} - -{% comment %} -Copyright (C) 2020 The Software Heritage developers -See the AUTHORS file at the top-level directory of this distribution -License: GNU Affero General Public License version 3, or any later version -See top-level LICENSE file for more information -{% endcomment %} - -{% load render_bundle from webpack_loader %} -{% load swh_templatetags %} - -{% block title %} Web API bearer tokens – Software Heritage API {% endblock %} - -{% block header %} -{% render_bundle 'auth' %} -{% endblock %} - -{% block navbar-content %} -

Web API bearer tokens management

-{% endblock %} - -{% block content %} - -

- That interface enables to manage bearer tokens for Web API authentication. - A token has to be sent in HTTP authorization headers to make authenticated API requests. -

-

- For instance when using curl proceed as follows: -

curl -H "Authorization: Bearer ${TOKEN}" {{ request.scheme }}://{{ request.META.HTTP_HOST }}/api/...
-

- -
-
- - -
- - - - - - - -
Creation dateActions
-
- - - -{% endblock content %} \ No newline at end of file diff --git a/swh/web/templates/auth/profile.html b/swh/web/templates/auth/profile.html new file mode 100644 --- /dev/null +++ b/swh/web/templates/auth/profile.html @@ -0,0 +1,105 @@ +{% extends "layout.html" %} + +{% comment %} +Copyright (C) 2020 The Software Heritage developers +See the AUTHORS file at the top-level directory of this distribution +License: GNU Affero General Public License version 3, or any later version +See top-level LICENSE file for more information +{% endcomment %} + +{% load render_bundle from webpack_loader %} +{% load swh_templatetags %} + +{% block title %} User profile – Software Heritage {% endblock %} + +{% block header %} +{% render_bundle 'auth' %} +{% endblock %} + +{% block navbar-content %} +

User profile

+{% endblock %} + +{% block content %} + + + +
+
+

+ Below are the details of your user account. + You can edit your personal information in the + + Software Heritage Account Management + interface. +

+ + + + + + + + + + + + + + + + + + + + + +
Username{{ user.username }}
First name{{ user.first_name }}
Last name{{ user.last_name }}
Email{{ user.email }}
Permissions: + {% for perm in user.get_all_permissions %} + {{ perm }}
+ {% endfor %} +
+
+ +
+

+ That interface enables to manage bearer tokens for Web API authentication. + A token has to be sent in HTTP authorization headers to make authenticated API requests. +

+

+ For instance when using curl proceed as follows: +

curl -H "Authorization: Bearer ${TOKEN}" {{ request.scheme }}://{{ request.META.HTTP_HOST }}/api/...
+

+
+
+ + +
+ + + + + + + +
Creation dateActions
+
+
+
+ + + +{% endblock content %} diff --git a/swh/web/templates/layout.html b/swh/web/templates/layout.html --- a/swh/web/templates/layout.html +++ b/swh/web/templates/layout.html @@ -1,5 +1,5 @@ {% comment %} -Copyright (C) 2015-2019 The Software Heritage developers +Copyright (C) 2015-2020 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information @@ -26,7 +26,7 @@ /* @licstart The following is the entire license notice for the JavaScript code in this page. -Copyright (C) 2015-2019 The Software Heritage developers +Copyright (C) 2015-2020 The Software Heritage developers This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as @@ -92,10 +92,12 @@
  • {% url 'logout' as logout_url %} {% if user.is_authenticated %} - Logged in as {{ user.username }}, + Logged in as {% if 'OIDC' in user.backend %} + {{ user.username }}, logout {% else %} + {{ user.username }}, logout {% endif %} {% elif oidc_enabled %} diff --git a/swh/web/tests/auth/test_views.py b/swh/web/tests/auth/test_views.py --- a/swh/web/tests/auth/test_views.py +++ b/swh/web/tests/auth/test_views.py @@ -16,6 +16,7 @@ from swh.web.auth.models import OIDCUser, OIDCUserOfflineTokens from swh.web.auth.utils import OIDC_SWH_WEB_CLIENT_ID from swh.web.common.utils import reverse +from swh.web.config import get_config from swh.web.tests.django_asserts import assert_contains from swh.web.tests.utils import ( check_html_get_response, @@ -525,3 +526,41 @@ status_code=401, data={"password": "invalid-password", "token_ids": [1]}, ) + + +def test_oidc_profile_view_anonymous_user(client): + """ + Non authenticated users should be redirected to login page when + requesting profile view. + """ + url = reverse("oidc-profile") + login_url = reverse("oidc-login", query_params={"next_path": url}) + resp = check_html_get_response(client, url, status_code=302) + assert resp["location"] == login_url + + +@pytest.mark.django_db +def test_oidc_profile_view(client, mocker): + """ + Authenticated users should be able to request the profile page + and link to Keycloak account UI should be present. + """ + url = reverse("oidc-profile") + kc_config = get_config()["keycloak"] + user_permissions = ["perm1", "perm2"] + mock_keycloak(mocker, user_permissions=user_permissions) + client.login(code="", code_verifier="", redirect_uri="") + resp = check_html_get_response( + client, url, status_code=200, template_used="auth/profile.html" + ) + user = resp.wsgi_request.user + kc_account_url = ( + f"{kc_config['server_url']}realms/{kc_config['realm_name']}/account/" + ) + assert_contains(resp, kc_account_url) + assert_contains(resp, user.username) + assert_contains(resp, user.first_name) + assert_contains(resp, user.last_name) + assert_contains(resp, user.email) + for perm in user_permissions: + assert_contains(resp, perm)