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 = + `
+

${infoText}

+ + + +
`; + 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.AuthConfig" 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 AuthConfig(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,63 @@ 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 + (see https://cryptography.io/en/latest/fernet/). + + 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 %} +

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/tests/auth/keycloak_mock.py b/swh/web/tests/auth/keycloak_mock.py --- a/swh/web/tests/auth/keycloak_mock.py +++ b/swh/web/tests/auth/keycloak_mock.py @@ -6,6 +6,8 @@ from copy import copy from unittest.mock import Mock +from keycloak.exceptions import KeycloakError + from django.utils import timezone from swh.web.auth.keycloak import KeycloakOpenIDConnect @@ -65,18 +67,23 @@ self.refresh_token = Mock() self.userinfo = Mock() self.logout = Mock() + self.offline_token = Mock() if auth_success: self.authorization_code.return_value = copy(oidc_profile) self.refresh_token.return_value = copy(oidc_profile) self.userinfo.return_value = copy(userinfo) + self.offline_token.return_value = oidc_profile["refresh_token"] else: self.authorization_url = Mock() - exception = Exception("Authentication failed") + exception = KeycloakError( + error_message="Authentication failed", response_code=401 + ) self.authorization_code.side_effect = exception self.authorization_url.side_effect = exception self.refresh_token.side_effect = exception self.userinfo.side_effect = exception self.logout.side_effect = exception + self.offline_token = exception def decode_token(self, token): options = {} diff --git a/swh/web/tests/auth/test_utils.py b/swh/web/tests/auth/test_utils.py --- a/swh/web/tests/auth/test_utils.py +++ b/swh/web/tests/auth/test_utils.py @@ -7,7 +7,10 @@ import hashlib import re -from swh.web.auth.utils import gen_oidc_pkce_codes +from cryptography.fernet import InvalidToken +import pytest + +from swh.web.auth.utils import decrypt_data, encrypt_data, gen_oidc_pkce_codes def test_gen_oidc_pkce_codes(): @@ -34,3 +37,29 @@ assert not code_challenge[-1].endswith("=") # check code challenge is valid assert code_challenge == challenge + + +def test_encrypt_decrypt_data_ok(): + data = b"some-data-to-encrypt" + password = b"secret" + salt = b"salt-value" + + encrypted_data = encrypt_data(data, password, salt) + decrypted_data = decrypt_data(encrypted_data, password, salt) + + assert decrypted_data == data + + +def test_encrypt_decrypt_data_ko(): + data = b"some-data-to-encrypt" + password1 = b"secret" + salt1 = b"salt-value" + + password2 = b"secret2" + salt2 = b"salt-value2" + + encrypted_data = encrypt_data(data, password1, salt1) + + for password, salt in ((password2, salt2), (password1, salt2), (password2, salt1)): + with pytest.raises(InvalidToken): + decrypt_data(encrypted_data, password2, salt2) 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 @@ -3,15 +3,17 @@ # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information +import json from urllib.parse import urljoin, urlparse import uuid +from keycloak.exceptions import KeycloakError import pytest from django.contrib.auth.models import AnonymousUser, User from django.http import QueryDict -from swh.web.auth.models import OIDCUser +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.tests.django_asserts import assert_contains, assert_template_used @@ -336,3 +338,196 @@ response = homepage_view(request) assert response.status_code == 200 + + +def test_oidc_generate_bearer_token_anonymous_user(client): + """ + Anonymous user should be refused access with forbidden response. + """ + url = reverse("oidc-generate-bearer-token") + response = client.post(url, data={"password": "secret"}) + assert response.status_code == 403 + + +def _generate_bearer_token(client, password): + client.login( + code="code", code_verifier="code-verifier", redirect_uri="redirect-uri" + ) + url = reverse("oidc-generate-bearer-token") + return client.post( + url, data={"password": password}, content_type="application/json" + ) + + +@pytest.mark.django_db +def test_oidc_generate_bearer_token_authenticated_user_success(client, mocker): + """ + User with correct credentials should be allowed to generate a token. + """ + kc_mock = mock_keycloak(mocker) + password = "secret" + response = _generate_bearer_token(client, password) + user = response.wsgi_request.user + assert response.status_code == 200 + assert response.content.decode("ascii") == kc_mock.offline_token( + username=user.username, password=password + ) + + +@pytest.mark.django_db +def test_oidc_generate_bearer_token_authenticated_user_failure(client, mocker): + """ + User with wrong credentials should not be allowed to generate a token. + """ + response_code = 401 + kc_mock = mock_keycloak(mocker) + kc_mock.offline_token.side_effect = KeycloakError( + error_message="Invalid password", response_code=response_code + ) + response = _generate_bearer_token(client, password="invalid-password") + assert response.status_code == response_code + + +def test_oidc_list_bearer_tokens_anonymous_user(client): + """ + Anonymous user should be refused access with forbidden response. + """ + url = reverse( + "oidc-list-bearer-tokens", query_params={"draw": 1, "start": 0, "length": 10} + ) + response = client.get(url) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_oidc_list_bearer_tokens(client, mocker): + """ + User with correct credentials should be allowed to list his tokens. + """ + mock_keycloak(mocker) + nb_tokens = 3 + password = "secret" + + for _ in range(nb_tokens): + response = _generate_bearer_token(client, password) + + url = reverse( + "oidc-list-bearer-tokens", query_params={"draw": 1, "start": 0, "length": 10} + ) + response = client.get(url) + assert response.status_code == 200 + tokens_data = list(reversed(json.loads(response.content.decode("utf-8"))["data"])) + + for oidc_token in OIDCUserOfflineTokens.objects.all(): + assert ( + oidc_token.creation_date.isoformat() + == tokens_data[oidc_token.id - 1]["creation_date"] + ) + + +def test_oidc_get_bearer_token_anonymous_user(client): + """ + Anonymous user should be refused access with forbidden response. + """ + url = reverse("oidc-get-bearer-token") + response = client.post(url) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_oidc_get_bearer_token(client, mocker): + """ + User with correct credentials should be allowed to display a token. + """ + mock_keycloak(mocker) + nb_tokens = 3 + password = "secret" + + for i in range(nb_tokens): + response = _generate_bearer_token(client, password) + token = response.content + + url = reverse("oidc-get-bearer-token") + response = client.post( + url, + data={"password": password, "token_id": i + 1}, + content_type="application/json", + ) + assert response.status_code == 200 + assert response.content == token + + +@pytest.mark.django_db +def test_oidc_get_bearer_token_invalid_password(client, mocker): + """ + User with wrong credentials should not be allowed to display a token. + """ + mock_keycloak(mocker) + password = "secret" + _generate_bearer_token(client, password) + + url = reverse("oidc-get-bearer-token") + response = client.post( + url, + data={"password": "invalid-password", "token_id": 1}, + content_type="application/json", + ) + assert response.status_code == 401 + + +def test_oidc_revoke_bearer_tokens_anonymous_user(client): + """ + Anonymous user should be refused access with forbidden response. + """ + url = reverse("oidc-revoke-bearer-tokens") + response = client.post(url) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_oidc_revoke_bearer_tokens(client, mocker): + """ + User with correct credentials should be allowed to revoke tokens. + """ + mock_keycloak(mocker) + nb_tokens = 3 + password = "secret" + + for _ in range(nb_tokens): + _generate_bearer_token(client, password) + + url = reverse("oidc-revoke-bearer-tokens") + response = client.post( + url, + data={"password": password, "token_ids": [1]}, + content_type="application/json", + ) + assert response.status_code == 200 + assert len(OIDCUserOfflineTokens.objects.all()) == 2 + + response = client.post( + url, + data={"password": password, "token_ids": [2, 3]}, + content_type="application/json", + ) + assert response.status_code == 200 + assert len(OIDCUserOfflineTokens.objects.all()) == 0 + + +@pytest.mark.django_db +def test_oidc_revoke_bearer_token_invalid_password(client, mocker): + """ + User with wrong credentials should not be allowed to revoke tokens. + """ + mock_keycloak(mocker) + password = "secret" + + _generate_bearer_token(client, password) + + url = reverse("oidc-revoke-bearer-tokens") + response = client.post( + url, + data={"password": "invalid-password", "token_ids": [1]}, + content_type="application/json", + ) + assert response.status_code == 401