diff --git a/cypress/integration/api-tokens.spec.js b/cypress/integration/api-tokens.spec.js new file mode 100644 --- /dev/null +++ b/cypress/integration/api-tokens.spec.js @@ -0,0 +1,211 @@ +/** + * 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 + */ + +describe('Test API tokens UI', function() { + + it('should ask for user to login', function() { + cy.visit(this.Urls.api_tokens(), {failOnStatusCode: false}); + cy.location().should(loc => { + expect(loc.pathname).to.eq(this.Urls.oidc_login()); + }); + }); + + function initTokensPage(Urls, tokens) { + cy.server(); + cy.route({ + method: 'GET', + url: `${Urls.oidc_list_bearer_tokens()}/**`, + response: { + '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.api_tokens()); + } + + function generateToken(Urls, status, tokenValue = '') { + cy.route({ + method: 'POST', + url: `${Urls.oidc_generate_bearer_token()}/**`, + response: tokenValue, + status: status + }).as('generateTokenRequest'); + + 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-user-password-submit') + .should('be.disabled'); + + cy.get('#swh-user-password') + .type('secret'); + + cy.get('#swh-user-password-submit') + .should('be.enabled'); + + cy.get('#swh-user-password-submit') + .click(); + + cy.wait('@generateTokenRequest'); + + if (status === 200) { + cy.get('#swh-user-password-submit') + .should('be.disabled'); + } + } + + it('should generate and display bearer token', function() { + initTokensPage(this.Urls, []); + const tokenValue = 'bearer-token-value'; + generateToken(this.Urls, 200, tokenValue); + cy.get('#swh-token-success-message') + .should('contain', 'Below is your token'); + cy.get('#swh-bearer-token') + .should('contain', tokenValue); + }); + + it('should report errors when token generation failed', function() { + initTokensPage(this.Urls, []); + generateToken(this.Urls, 400); + cy.get('#swh-token-error-message') + .should('contain', 'You are not allowed to generate bearer tokens'); + cy.get('#swh-web-modal-html .close').click(); + generateToken(this.Urls, 401); + cy.get('#swh-token-error-message') + .should('contain', 'The password is invalid'); + cy.get('#swh-web-modal-html .close').click(); + generateToken(this.Urls, 500); + cy.get('#swh-token-error-message') + .should('contain', 'Internal server error'); + + }); + + function displayToken(Urls, status, tokenValue = '') { + cy.route({ + method: 'POST', + url: `${Urls.oidc_get_bearer_token()}/**`, + response: tokenValue, + status: status + }).as('getTokenRequest'); + + cy.contains('Display token') + .click(); + + cy.get('.modal-dialog') + .should('be.visible'); + + cy.get('.modal-header') + .should('contain', 'Display bearer token'); + + cy.get('#swh-user-password-submit') + .should('be.disabled'); + + cy.get('#swh-user-password') + .type('secret'); + + cy.get('#swh-user-password-submit') + .should('be.enabled'); + + cy.get('#swh-user-password-submit') + .click(); + + cy.wait('@getTokenRequest'); + + if (status === 200) { + cy.get('#swh-user-password-submit') + .should('be.disabled'); + } + } + + 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-token-success-message') + .should('contain', 'Below is your token'); + cy.get('#swh-bearer-token') + .should('contain', tokenValue); + }); + + it('should report errors when token display failed', function() { + initTokensPage(this.Urls, [{id: 1, creation_date: new Date().toISOString()}]); + displayToken(this.Urls, 401); + cy.get('#swh-token-error-message') + .should('contain', 'The password is invalid'); + cy.get('#swh-web-modal-html .close').click(); + displayToken(this.Urls, 500); + cy.get('#swh-token-error-message') + .should('contain', 'Internal server error'); + }); + + function revokeToken(Urls, status) { + cy.route({ + method: 'POST', + url: `${Urls.oidc_revoke_bearer_tokens()}/**`, + response: '', + status: status + }).as('revokeTokenRequest'); + + cy.contains('Revoke token') + .click(); + + cy.get('.modal-dialog') + .should('be.visible'); + + cy.get('.modal-header') + .should('contain', 'Revoke bearer token'); + + cy.get('#swh-user-password-submit') + .should('be.disabled'); + + cy.get('#swh-user-password') + .type('secret'); + + cy.get('#swh-user-password-submit') + .should('be.enabled'); + + cy.get('#swh-user-password-submit') + .click(); + + cy.wait('@revokeTokenRequest'); + + if (status === 200) { + cy.get('#swh-user-password-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-success-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, 401); + cy.get('#swh-token-error-message') + .should('contain', 'The password is invalid'); + cy.get('#swh-web-modal-html .close').click(); + revokeToken(this.Urls, 500); + cy.get('#swh-token-error-message') + .should('contain', 'Internal server error'); + }); + +}); diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ # dependency lines, see https://pip.readthedocs.org/en/1.1/requirements.html beautifulsoup4 +cryptography django < 3 django-cors-headers django-js-reverse 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,6 +3,10 @@ # 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 @@ -16,4 +20,11 @@ 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/auth.css b/swh/web/assets/src/bundles/auth/auth.css new file mode 100644 --- /dev/null +++ b/swh/web/assets/src/bundles/auth/auth.css @@ -0,0 +1,21 @@ +/** + * 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 + */ + +#swh-token-error-message { + color: red; + font-weight: bold; +} + +#swh-token-success-message { + color: green; + font-weight: bold; +} + +#swh-bearer-token { + word-wrap: break-word; + white-space: break-spaces; +} diff --git a/swh/web/assets/src/bundles/auth/index.js b/swh/web/assets/src/bundles/auth/index.js new file mode 100644 --- /dev/null +++ b/swh/web/assets/src/bundles/auth/index.js @@ -0,0 +1,216 @@ +/** + * 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} from 'utils/functions'; +import './auth.css'; + +let apiTokensTable; + +function updateSubmitButtonState() { + const val = $('#swh-user-password').val(); + $('#swh-user-password-submit').prop('disabled', val.length === 0); +} + +function passwordForm(infoText, buttonText) { + const form = + `
`; + return form; +} + +function errorMessage(message) { + return `${message}
`; +} + +function successMessage(message) { + return `${message}
`; +} + +function disableSubmitButton() { + $('#swh-user-password-submit').prop('disabled', true); + $('#swh-user-password').off('change'); + $('#swh-user-password').off('keyup'); +} + +function generateToken() { + csrfPost(Urls.oidc_generate_bearer_token(), {}, + JSON.stringify({password: $('#swh-user-password').val()})) + .then(handleFetchError) + .then(response => response.text()) + .then(token => { + disableSubmitButton(); + const tokenHtml = + `${successMessage('Below is your token.')} +${token}`; + $(`#swh-password-form`).append(tokenHtml); + apiTokensTable.draw(); + }) + .catch(response => { + if (response.status === 400) { + $(`#swh-password-form`).append( + errorMessage('You are not allowed to generate bearer tokens.')); + } else if (response.status === 401) { + $(`#swh-password-form`).append(errorMessage('The password is invalid.')); + } else { + $(`#swh-password-form`).append(errorMessage('Internal server error.')); + } + }); +} + +function displayToken(tokenId) { + const postData = { + password: $('#swh-user-password').val(), + token_id: tokenId + }; + csrfPost(Urls.oidc_get_bearer_token(), {}, JSON.stringify(postData)) + .then(handleFetchError) + .then(response => response.text()) + .then(token => { + disableSubmitButton(); + const tokenHtml = + `${successMessage('Below is your token.')} +
${token}`; + $(`#swh-password-form`).append(tokenHtml); + }) + .catch(response => { + if (response.status === 401) { + $(`#swh-password-form`).append(errorMessage('The password is invalid.')); + } else { + $(`#swh-password-form`).append(errorMessage('Internal server error.')); + } + }); +} + +function revokeTokens(tokenIds) { + const postData = { + password: $('#swh-user-password').val(), + token_ids: tokenIds + }; + csrfPost(Urls.oidc_revoke_bearer_tokens(), {}, JSON.stringify(postData)) + .then(handleFetchError) + .then(() => { + disableSubmitButton(); + $(`#swh-password-form`).append( + successMessage(`Bearer token${tokenIds.length > 1 ? 's' : ''} successfully revoked`)); + apiTokensTable.draw(); + }) + .catch(response => { + if (response.status === 401) { + $(`#swh-password-form`).append(errorMessage('The password is invalid.')); + } else { + $(`#swh-password-form`).append(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 = { + generate: { + modalTitle: 'Bearer token generation', + infoText: 'Enter your password and click on the button to generate the token.', + buttonText: 'Generate token', + submitCallback: generateToken + }, + display: { + modalTitle: 'Display bearer token', + infoText: 'Enter your password and click on the button to display the token.', + buttonText: 'Display token', + submitCallback: displayToken + }, + revoke: { + modalTitle: 'Revoke bearer token', + infoText: 'Enter your password and click on the button to revoke the token.', + buttonText: 'Revoke token', + submitCallback: revokeToken + }, + revokeAll: { + modalTitle: 'Revoke all bearer tokens', + infoText: 'Enter your password and click on the button to revoke all tokens.', + buttonText: 'Revoke tokens', + submitCallback: revokeAllTokens + } + }; + + if (!actionData[action]) { + return; + } + + const passwordFormHtml = passwordForm( + actionData[action].infoText, actionData[action].buttonText); + + swh.webapp.showModalHtml(actionData[action].modalTitle, passwordFormHtml); + $('#swh-user-password').change(updateSubmitButtonState); + $('#swh-user-password').keyup(updateSubmitButtonState); + $(`#swh-password-form`).submit(event => { + event.preventDefault(); + event.stopPropagation(); + actionData[action].submitCallback(tokenId); + }); +} + +export function initApiTokensPage() { + $(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 + }); + }); +} diff --git a/swh/web/auth/__init__.py b/swh/web/auth/__init__.py --- a/swh/web/auth/__init__.py +++ b/swh/web/auth/__init__.py @@ -0,0 +1,6 @@ +# 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 + +default_app_config = "swh.web.auth.apps.SwhWebAuthConfig" diff --git a/swh/web/auth/apps.py b/swh/web/auth/apps.py new file mode 100644 --- /dev/null +++ b/swh/web/auth/apps.py @@ -0,0 +1,11 @@ +# 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 + +from django.apps import AppConfig + + +class SwhWebAuthConfig(AppConfig): + name = "swh.web.auth" + label = "swh.web.auth" diff --git a/swh/web/auth/keycloak.py b/swh/web/auth/keycloak.py --- a/swh/web/auth/keycloak.py +++ b/swh/web/auth/keycloak.py @@ -72,7 +72,7 @@ Args: code: Authorization code provided by Keycloak redirect_uri: URI to redirect to once a user is authenticated - (must be the same as the one provided to authorization_url) + (must be the same as the one provided to authorization_url): extra_params: Extra parameters to add in the authorization request payload. """ @@ -83,6 +83,27 @@ **extra_params, ) + def offline_token(self, username: str, password: str) -> str: + """ + Generate an OpenID Connect offline refresh token. + + Offline tokens are a special type of refresh tokens with long-lived period. + It enables to open a new authenticated session without having to login again. + + Args: + username: username in the Keycloak realm + password: password associated to the username + + Returns: + An offline refresh token + """ + return self._keycloak.token( + grant_type="password", + scope="openid offline_access", + username=username, + password=password, + )["refresh_token"] + def refresh_token(self, refresh_token: str) -> Dict[str, Any]: """ Request a new access token from Keycloak using a refresh token. diff --git a/swh/web/auth/migrations/0001_initial.py b/swh/web/auth/migrations/0001_initial.py new file mode 100644 --- /dev/null +++ b/swh/web/auth/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# 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 django.contrib.auth.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0011_update_proxy_permissions"), + ] + + operations = [ + migrations.CreateModel( + name="OIDCUserOfflineTokens", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("user_id", models.CharField(max_length=50)), + ("creation_date", models.DateTimeField(auto_now_add=True)), + ("offline_token", models.BinaryField()), + ], + options={"db_table": "oidc_user_offline_tokens",}, + ), + migrations.CreateModel( + name="OIDCUser", + fields=[], + options={"proxy": True, "indexes": [], "constraints": [],}, + bases=("auth.user",), + managers=[("objects", django.contrib.auth.models.UserManager()),], + ), + ] diff --git a/swh/web/auth/__init__.py b/swh/web/auth/migrations/__init__.py copy from swh/web/auth/__init__.py copy to swh/web/auth/migrations/__init__.py diff --git a/swh/web/auth/models.py b/swh/web/auth/models.py --- a/swh/web/auth/models.py +++ b/swh/web/auth/models.py @@ -7,6 +7,7 @@ from typing import Optional, Set from django.contrib.auth.models import User +from django.db import models class OIDCUser(User): @@ -78,3 +79,17 @@ return True return any(perm.startswith(app_label) for perm in self.permissions) + + +class OIDCUserOfflineTokens(models.Model): + """ + Model storing encrypted bearer tokens generated by users. + """ + + user_id = models.CharField(max_length=50) + creation_date = models.DateTimeField(auto_now_add=True) + offline_token = models.BinaryField() + + class Meta: + app_label = "swh.web.auth" + db_table = "oidc_user_offline_tokens" diff --git a/swh/web/auth/utils.py b/swh/web/auth/utils.py --- a/swh/web/auth/utils.py +++ b/swh/web/auth/utils.py @@ -8,6 +8,11 @@ import secrets from typing import Tuple +from cryptography.fernet import Fernet +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + from swh.web.auth.keycloak import KeycloakOpenIDConnect, get_keycloak_oidc_client from swh.web.config import get_config @@ -41,6 +46,62 @@ OIDC_SWH_WEB_CLIENT_ID = "swh-web" +def _get_fernet(password: bytes, salt: bytes) -> Fernet: + """ + Instantiate a Fernet system from a password and a salt value. + + Args: + password: user password that will be used to generate a Fernet key + derivation function + salt: value that will be used to generate a Fernet key + derivation function + + Returns: + The Fernet system + """ + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + backend=default_backend(), + ) + key = urlsafe_b64encode(kdf.derive(password)) + return Fernet(key) + + +def encrypt_data(data: bytes, password: bytes, salt: bytes) -> bytes: + """ + Encrypt data using Fernet system (symmetric encryption). + + Args: + data: input data to encrypt + password: user password that will be used to generate a Fernet key + derivation function + salt: value that will be used to generate a Fernet key + derivation function + Returns: + The encrypted data + """ + return _get_fernet(password, salt).encrypt(data) + + +def decrypt_data(data: bytes, password: bytes, salt: bytes) -> bytes: + """ + Decrypt data using Fernet system (symmetric encryption). + + Args: + data: input data to decrypt + password: user password that will be used to generate a Fernet key + derivation function + salt: value that will be used to generate a Fernet key + derivation function + Returns: + The decrypted data + """ + return _get_fernet(password, salt).decrypt(data) + + def get_oidc_client(client_id: str = OIDC_SWH_WEB_CLIENT_ID) -> KeycloakOpenIDConnect: """ Instantiate a KeycloakOpenIDConnect class for a given client in the 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 @@ -3,21 +3,35 @@ # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information -from typing import cast +import json +from typing import Any, Dict, cast import uuid +from cryptography.fernet import InvalidToken +from keycloak.exceptions import KeycloakError +import sentry_sdk + from django.conf.urls import url from django.contrib.auth import authenticate, login, logout from django.core.cache import cache +from django.core.paginator import Paginator from django.http import HttpRequest from django.http.response import ( HttpResponse, + HttpResponseForbidden, HttpResponseRedirect, HttpResponseServerError, + JsonResponse, ) +from django.views.decorators.http import require_http_methods -from swh.web.auth.models import OIDCUser -from swh.web.auth.utils import gen_oidc_pkce_codes, get_oidc_client +from swh.web.auth.models import OIDCUser, OIDCUserOfflineTokens +from swh.web.auth.utils import ( + decrypt_data, + encrypt_data, + gen_oidc_pkce_codes, + get_oidc_client, +) from swh.web.common.exc import BadInputExc, handle_view_exception from swh.web.common.utils import reverse @@ -129,8 +143,119 @@ return handle_view_exception(request, e) +@require_http_methods(["POST"]) +def oidc_generate_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("utf-8")) + user = cast(OIDCUser, request.user) + oidc_client = get_oidc_client() + token = oidc_client.offline_token(user.username, data["password"]) + password = data["password"].encode() + salt = user.sub.encode() + encrypted_token = encrypt_data(token.encode(), password, salt) + OIDCUserOfflineTokens.objects.create( + user_id=str(user.id), offline_token=encrypted_token + ).save() + return HttpResponse(token, content_type="text/plain") + except KeycloakError as e: + sentry_sdk.capture_exception(e) + return HttpResponse(status=e.response_code or 500) + except Exception as e: + sentry_sdk.capture_exception(e) + return HttpResponseServerError(str(e)) + + +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) + + +@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("utf-8")) + user = cast(OIDCUser, request.user) + token_data = OIDCUserOfflineTokens.objects.get(id=data["token_id"]) + password = data["password"].encode() + salt = user.sub.encode() + decrypted_token = decrypt_data(token_data.offline_token, password, salt) + return HttpResponse(decrypted_token.decode("ascii"), content_type="text/plain") + except InvalidToken: + return HttpResponse(status=401) + except Exception as e: + sentry_sdk.capture_exception(e) + return HttpResponseServerError(str(e)) + + +@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("utf-8")) + user = cast(OIDCUser, request.user) + for token_id in data["token_ids"]: + token_data = OIDCUserOfflineTokens.objects.get(id=token_id) + password = data["password"].encode() + salt = user.sub.encode() + decrypted_token = decrypt_data(token_data.offline_token, password, salt) + oidc_client = get_oidc_client() + oidc_client.logout(decrypted_token.decode("ascii")) + token_data.delete() + return HttpResponse(status=200) + except InvalidToken: + return HttpResponse(status=401) + except Exception as e: + sentry_sdk.capture_exception(e) + return HttpResponseServerError(str(e)) + + urlpatterns = [ url(r"^oidc/login/$", oidc_login, name="oidc-login"), url(r"^oidc/login-complete/$", oidc_login_complete, name="oidc-login-complete"), url(r"^oidc/logout/$", oidc_logout, name="oidc-logout"), + url( + r"^oidc/generate-bearer-token/$", + oidc_generate_bearer_token, + name="oidc-generate-bearer-token", + ), + 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", + ), ] diff --git a/swh/web/settings/common.py b/swh/web/settings/common.py --- a/swh/web/settings/common.py +++ b/swh/web/settings/common.py @@ -43,6 +43,7 @@ "rest_framework", "swh.web.common", "swh.web.api", + "swh.web.auth", "swh.web.browse", "webpack_loader", "django_js_reverse", diff --git a/swh/web/templates/api/tokens.html b/swh/web/templates/api/tokens.html new file mode 100644 --- /dev/null +++ b/swh/web/templates/api/tokens.html @@ -0,0 +1,57 @@ +{% 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 %} +
+ 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 date | +Actions | +
---|