diff --git a/assets/src/bundles/auth/index.js b/assets/src/bundles/auth/index.js index 4156b4b9..652736b1 100644 --- a/assets/src/bundles/auth/index.js +++ b/assets/src/bundles/auth/index.js @@ -1,187 +1,193 @@ /** * 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 */ import {handleFetchError, csrfPost, removeUrlFragment} from 'utils/functions'; import './auth.css'; let apiTokensTable; function tokenForm(infoText, buttonText) { const form = `
`; return form; } function errorMessage(message) { return ` `; } function successMessage(message) { return ` `; } function disableSubmitButton() { $('#swh-token-form-submit').prop('disabled', true); } function generateToken() { window.location = Urls.oidc_generate_bearer_token(); } function displayToken(tokenId) { const postData = { token_id: tokenId }; csrfPost(Urls.oidc_get_bearer_token(), {}, JSON.stringify(postData)) .then(handleFetchError) .then(response => response.text()) .then(token => { const tokenHtml = `Below is your token.
${token}`; swh.webapp.showModalHtml('Display bearer token', tokenHtml); }) - .catch(() => { - swh.webapp.showModalHtml('Display bearer token', errorMessage('Internal server error.')); + .catch(response => { + response.text().then(responseText => { + let errorMsg = 'Internal server error.'; + if (response.status === 400) { + errorMsg = responseText; + } + swh.webapp.showModalHtml('Display bearer token', errorMessage(errorMsg)); + }); }); } function revokeTokens(tokenIds) { const postData = { token_ids: tokenIds }; csrfPost(Urls.oidc_revoke_bearer_tokens(), {}, JSON.stringify(postData)) .then(handleFetchError) .then(() => { disableSubmitButton(); $('#swh-token-form-message').html( successMessage(`Bearer token${tokenIds.length > 1 ? 's' : ''} successfully revoked.`)); apiTokensTable.draw(); }) .catch(() => { $('#swh-token-form-message').html(errorMessage('Internal server error.')); }); } function revokeToken(tokenId) { revokeTokens([tokenId]); } function revokeAllTokens() { const tokenIds = []; const rowsData = apiTokensTable.rows().data(); for (let i = 0; i < rowsData.length; ++i) { tokenIds.push(rowsData[i].id); } revokeTokens(tokenIds); } export function applyTokenAction(action, tokenId) { const actionData = { display: { submitCallback: displayToken }, generate: { modalTitle: 'Bearer token generation', infoText: 'Click on the button to generate the token. You will be redirected to ' + 'Software Heritage Authentication Service and might be asked to enter ' + 'your password again.', buttonText: 'Generate token', submitCallback: generateToken }, revoke: { modalTitle: 'Revoke bearer token', infoText: 'Click on the button to revoke the token.', buttonText: 'Revoke token', submitCallback: revokeToken }, revokeAll: { modalTitle: 'Revoke all bearer tokens', infoText: 'Click on the button to revoke all tokens.', buttonText: 'Revoke tokens', submitCallback: revokeAllTokens } }; if (!actionData[action]) { return; } if (action !== 'display') { const tokenFormHtml = tokenForm( actionData[action].infoText, actionData[action].buttonText); swh.webapp.showModalHtml(actionData[action].modalTitle, tokenFormHtml); $(`#swh-token-form`).submit(event => { event.preventDefault(); event.stopPropagation(); actionData[action].submitCallback(tokenId); }); } else { actionData[action].submitCallback(tokenId); } } export function initProfilePage() { $(document).ready(() => { apiTokensTable = $('#swh-bearer-tokens-table') .on('error.dt', (e, settings, techNote, message) => { $('#swh-origin-save-request-list-error').text( 'An error occurred while retrieving the tokens list'); console.log(message); }) .DataTable({ serverSide: true, ajax: Urls.oidc_list_bearer_tokens(), columns: [ { data: 'creation_date', name: 'creation_date', render: (data, type, row) => { if (type === 'display') { let date = new Date(data); return date.toLocaleString(); } return data; } }, { render: (data, type, row) => { const html = ` `; return html; } } ], ordering: false, searching: false, 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/cypress/integration/api-tokens.spec.js b/cypress/integration/api-tokens.spec.js index 7e1c3a22..2e858b84 100644 --- a/cypress/integration/api-tokens.spec.js +++ b/cypress/integration/api-tokens.spec.js @@ -1,133 +1,142 @@ /** * Copyright (C) 2020-2021 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 */ describe('Test API tokens UI', function() { it('should ask for user to login', function() { cy.visit(`${this.Urls.oidc_profile()}#tokens`, {failOnStatusCode: false}); cy.location().should(loc => { expect(loc.pathname).to.eq(this.Urls.oidc_login()); }); }); function initTokensPage(Urls, tokens) { cy.intercept(`${Urls.oidc_list_bearer_tokens()}/**`, { body: { 'recordsTotal': tokens.length, 'draw': 2, 'recordsFiltered': tokens.length, 'data': tokens } }); // 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.oidc_profile()}#tokens`); } it('should initiate token generation flow', function() { initTokensPage(this.Urls, []); cy.contains('Generate new token') .click(); cy.get('.modal-dialog') .should('be.visible'); cy.get('.modal-header') .should('contain', 'Bearer token generation'); cy.get('#swh-token-form-submit') .click(); cy.location().should(loc => { expect(loc.pathname).to.eq(this.Urls.oidc_generate_bearer_token()); }); }); it('should report error when not logged in and visiting a token generation URL', function() { cy.visit(this.Urls.oidc_generate_bearer_token_complete(), {failOnStatusCode: false}); cy.get('.swh-http-error') .should('be.visible'); cy.get('.swh-http-error-code') .should('contain', 403); cy.get('.swh-http-error-desc') .should('contain', 'You are not allowed to generate bearer tokens'); }); - function displayToken(Urls, status, tokenValue = '') { + function displayToken(Urls, status, body = '') { cy.intercept('POST', `${Urls.oidc_get_bearer_token()}/**`, { - body: tokenValue, + body: body, statusCode: status }).as('getTokenRequest'); cy.contains('Display token') .click({force: true}); cy.get('.modal-dialog') .should('be.visible'); cy.get('.modal-header') .should('contain', 'Display bearer token'); } it('should show a token when requested', function() { initTokensPage(this.Urls, [{id: 1, creation_date: new Date().toISOString()}]); const tokenValue = 'token-value'; displayToken(this.Urls, 200, tokenValue); cy.get('#swh-bearer-token') .should('contain', tokenValue); }); - it('should report errors when token display failed', function() { + it('should report error when token display failed', function() { initTokensPage(this.Urls, [{id: 1, creation_date: new Date().toISOString()}]); - displayToken(this.Urls, 500); + const errorMessage = 'Internal server error'; + displayToken(this.Urls, 500, errorMessage); cy.get('.modal-body') - .should('contain', 'Internal server error'); + .should('contain', errorMessage); + }); + + it('should report error when token expired', function() { + initTokensPage(this.Urls, [{id: 1, creation_date: new Date().toISOString()}]); + const errorMessage = 'Bearer token has expired'; + displayToken(this.Urls, 400, errorMessage); + cy.get('.modal-body') + .should('contain', errorMessage); }); function revokeToken(Urls, status) { cy.intercept('POST', `${Urls.oidc_revoke_bearer_tokens()}/**`, { body: '', statusCode: status }).as('revokeTokenRequest'); cy.contains('Revoke token') .click({force: true}); cy.get('.modal-dialog') .should('be.visible'); cy.get('.modal-header') .should('contain', 'Revoke bearer token'); cy.get('#swh-token-form-submit') .click({force: true}); cy.wait('@revokeTokenRequest'); if (status === 200) { cy.get('#swh-token-form-submit') .should('be.disabled'); } } it('should revoke a token when requested', function() { initTokensPage(this.Urls, [{id: 1, creation_date: new Date().toISOString()}]); revokeToken(this.Urls, 200); cy.get('#swh-token-form-message') .should('contain', 'Bearer token successfully revoked'); }); it('should report errors when token revoke failed', function() { initTokensPage(this.Urls, [{id: 1, creation_date: new Date().toISOString()}]); revokeToken(this.Urls, 500); cy.get('#swh-token-error-message') .should('contain', 'Internal server error'); }); }); diff --git a/swh/web/auth/views.py b/swh/web/auth/views.py index 1dce5c7b..ecd3ff94 100644 --- a/swh/web/auth/views.py +++ b/swh/web/auth/views.py @@ -1,173 +1,187 @@ # 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 import json from typing import Any, Dict, Union, cast from cryptography.fernet import InvalidToken from django.conf.urls import url from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.http import HttpRequest from django.http.response import ( HttpResponse, + HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect, JsonResponse, ) from django.shortcuts import render from django.views.decorators.http import require_http_methods from swh.auth.django.models import OIDCUser from swh.auth.django.utils import keycloak_oidc_client from swh.auth.django.views import get_oidc_login_data, oidc_login_view from swh.auth.django.views import urlpatterns as auth_urlpatterns +from swh.auth.keycloak import KeycloakError, keycloak_error_message from swh.web.auth.models import OIDCUserOfflineTokens from swh.web.auth.utils import decrypt_data, encrypt_data from swh.web.common.exc import ForbiddenExc from swh.web.common.utils import reverse from swh.web.config import get_config def oidc_generate_bearer_token(request: HttpRequest) -> HttpResponse: if not request.user.is_authenticated or not isinstance(request.user, OIDCUser): return HttpResponseForbidden() redirect_uri = reverse("oidc-generate-bearer-token-complete", request=request) return oidc_login_view( request, redirect_uri=redirect_uri, scope="openid offline_access" ) def oidc_generate_bearer_token_complete(request: HttpRequest) -> HttpResponse: if not request.user.is_authenticated or not isinstance(request.user, OIDCUser): raise ForbiddenExc("You are not allowed to generate bearer tokens.") if "error" in request.GET: raise Exception(request.GET["error"]) login_data = get_oidc_login_data(request) oidc_client = keycloak_oidc_client() oidc_profile = oidc_client.authorization_code( code=request.GET["code"], code_verifier=login_data["code_verifier"], redirect_uri=login_data["redirect_uri"], ) user = cast(OIDCUser, request.user) token = oidc_profile["refresh_token"] secret = get_config()["secret_key"].encode() salt = user.sub.encode() encrypted_token = encrypt_data(token.encode(), secret, salt) OIDCUserOfflineTokens.objects.create( user_id=str(user.id), offline_token=encrypted_token ).save() return HttpResponseRedirect(reverse("oidc-profile") + "#tokens") def oidc_list_bearer_tokens(request: HttpRequest) -> HttpResponse: if not request.user.is_authenticated or not isinstance(request.user, OIDCUser): return HttpResponseForbidden() tokens = OIDCUserOfflineTokens.objects.filter(user_id=str(request.user.id)) tokens = tokens.order_by("-creation_date") length = int(request.GET["length"]) page = int(request.GET["start"]) / length + 1 paginator = Paginator(tokens, length) tokens_data = [ {"id": t.id, "creation_date": t.creation_date.isoformat()} for t in paginator.page(int(page)).object_list ] table_data: Dict[str, Any] = {} table_data["recordsTotal"] = len(tokens_data) table_data["draw"] = int(request.GET["draw"]) table_data["data"] = tokens_data table_data["recordsFiltered"] = len(tokens_data) return JsonResponse(table_data) def _encrypted_token_bytes(token: Union[bytes, memoryview]) -> bytes: # token has been retrieved from a PosgreSQL database if isinstance(token, memoryview): return token.tobytes() else: return token @require_http_methods(["POST"]) def oidc_get_bearer_token(request: HttpRequest) -> HttpResponse: if not request.user.is_authenticated or not isinstance(request.user, OIDCUser): return HttpResponseForbidden() try: data = json.loads(request.body.decode("ascii")) user = cast(OIDCUser, request.user) token_data = OIDCUserOfflineTokens.objects.get(id=data["token_id"]) secret = get_config()["secret_key"].encode() salt = user.sub.encode() decrypted_token = decrypt_data( _encrypted_token_bytes(token_data.offline_token), secret, salt ) - return HttpResponse(decrypted_token.decode("ascii"), content_type="text/plain") + refresh_token = decrypted_token.decode("ascii") + # check token is still valid + oidc_client = keycloak_oidc_client() + oidc_client.refresh_token(refresh_token) + return HttpResponse(refresh_token, content_type="text/plain") except InvalidToken: return HttpResponse(status=401) + except KeycloakError as ke: + error_msg = keycloak_error_message(ke) + if error_msg in ( + "invalid_grant: Offline session not active", + "invalid_grant: Offline user session not found", + ): + error_msg = "Bearer token has expired, please generate a new one." + return HttpResponseBadRequest(error_msg, content_type="text/plain") @require_http_methods(["POST"]) def oidc_revoke_bearer_tokens(request: HttpRequest) -> HttpResponse: if not request.user.is_authenticated or not isinstance(request.user, OIDCUser): return HttpResponseForbidden() try: data = json.loads(request.body.decode("ascii")) user = cast(OIDCUser, request.user) for token_id in data["token_ids"]: token_data = OIDCUserOfflineTokens.objects.get(id=token_id) secret = get_config()["secret_key"].encode() salt = user.sub.encode() decrypted_token = decrypt_data( _encrypted_token_bytes(token_data.offline_token), secret, salt ) oidc_client = keycloak_oidc_client() oidc_client.logout(decrypted_token.decode("ascii")) token_data.delete() return HttpResponse(status=200) except InvalidToken: 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 = auth_urlpatterns + [ url( r"^oidc/generate-bearer-token/$", oidc_generate_bearer_token, name="oidc-generate-bearer-token", ), url( r"^oidc/generate-bearer-token-complete/$", oidc_generate_bearer_token_complete, name="oidc-generate-bearer-token-complete", ), url( r"^oidc/list-bearer-token/$", oidc_list_bearer_tokens, name="oidc-list-bearer-tokens", ), url( r"^oidc/get-bearer-token/$", oidc_get_bearer_token, name="oidc-get-bearer-token", ), url( r"^oidc/revoke-bearer-tokens/$", oidc_revoke_bearer_tokens, name="oidc-revoke-bearer-tokens", ), url(r"^oidc/profile/$", _oidc_profile_view, name="oidc-profile",), ] diff --git a/swh/web/templates/auth/profile.html b/swh/web/templates/auth/profile.html index 82e023f3..56cc96a2 100644 --- a/swh/web/templates/auth/profile.html +++ b/swh/web/templates/auth/profile.html @@ -1,105 +1,108 @@ {% 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 %}
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 }} |
{{ 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}" {{ site_base_url }}api/...+
+ Please not that a bearer token will automatically expire after 30 days of inactivity. +
Creation date | Actions |
---|