diff --git a/cypress/integration/api-tokens.spec.js b/cypress/integration/api-tokens.spec.js new file mode 100644 index 00000000..90ac8e1c --- /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 index 17fb4438..c0924dde 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,24 +1,25 @@ # Add here external Python modules dependencies, one per line. Module names # should match https://pypi.python.org/pypi names. For the full spec or # dependency lines, see https://pip.readthedocs.org/en/1.1/requirements.html beautifulsoup4 +cryptography django < 3 django-cors-headers django-js-reverse djangorestframework django-webpack-loader docutils htmlmin iso8601 lxml prometheus-client pybadges pygments python-keycloak >= 0.19.0 python-magic >= 0.4.0 python-memcached pyyaml requests sentry-sdk typing-extensions diff --git a/swh/web/api/urls.py b/swh/web/api/urls.py index f1b5054f..9b1bb6b7 100644 --- a/swh/web/api/urls.py +++ b/swh/web/api/urls.py @@ -1,19 +1,30 @@ # Copyright (C) 2017-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.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 import swh.web.api.views.identifiers # noqa import swh.web.api.views.origin # noqa import swh.web.api.views.origin_save # noqa import swh.web.api.views.ping # noqa import swh.web.api.views.release # noqa import swh.web.api.views.revision # noqa import swh.web.api.views.snapshot # noqa 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 index 00000000..2f12a38c --- /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 index 00000000..1b67e5e1 --- /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 index e69de29b..bc689466 100644 --- 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 index 00000000..45034773 --- /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 index 4e5fa2af..2831931b 100644 --- a/swh/web/auth/keycloak.py +++ b/swh/web/auth/keycloak.py @@ -1,168 +1,189 @@ # 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 typing import Any, Dict, Optional, Tuple from urllib.parse import urlencode from keycloak import KeycloakOpenID class KeycloakOpenIDConnect: """ Wrapper class around python-keycloak to ease the interaction with Keycloak for managing authentication and user permissions with OpenID Connect. """ def __init__( self, server_url: str, realm_name: str, client_id: str, realm_public_key: str = "", ): """ Args: server_url: URL of the Keycloak server realm_name: The realm name client_id: The OpenID Connect client identifier realm_public_key: The realm public key (will be dynamically retrieved if not provided) """ self._keycloak = KeycloakOpenID( server_url=server_url, client_id=client_id, realm_name=realm_name, ) self.server_url = server_url self.realm_name = realm_name self.client_id = client_id self.realm_public_key = realm_public_key def well_known(self) -> Dict[str, Any]: """ Retrieve the OpenID Connect Well-Known URI registry from Keycloak. Returns: A dictionary filled with OpenID Connect URIS. """ return self._keycloak.well_know() def authorization_url(self, redirect_uri: str, **extra_params: str) -> str: """ Get OpenID Connect authorization URL to authenticate users. Args: redirect_uri: URI to redirect to once a user is authenticated extra_params: Extra query parameters to add to the authorization URL """ auth_url = self._keycloak.auth_url(redirect_uri) if extra_params: auth_url += "&%s" % urlencode(extra_params) return auth_url def authorization_code( self, code: str, redirect_uri: str, **extra_params: str ) -> Dict[str, Any]: """ Get OpenID Connect authentication tokens using Authorization Code flow. 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. """ return self._keycloak.token( grant_type="authorization_code", code=code, redirect_uri=redirect_uri, **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. Args: refresh_token: A refresh token provided by Keycloak Returns: A dictionary filled with tokens info """ return self._keycloak.refresh_token(refresh_token) def decode_token( self, token: str, options: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Try to decode a JWT token. Args: token: A JWT token to decode options: Options for jose.jwt.decode Returns: A dictionary filled with decoded token content """ if not self.realm_public_key: realm_public_key = self._keycloak.public_key() self.realm_public_key = "-----BEGIN PUBLIC KEY-----\n" self.realm_public_key += realm_public_key self.realm_public_key += "\n-----END PUBLIC KEY-----" return self._keycloak.decode_token( token, key=self.realm_public_key, options=options ) def logout(self, refresh_token: str) -> None: """ Logout a user by closing its authenticated session. Args: refresh_token: A refresh token provided by Keycloak """ self._keycloak.logout(refresh_token) def userinfo(self, access_token: str) -> Dict[str, Any]: """ Return user information from its access token. Args: access_token: An access token provided by Keycloak Returns: A dictionary fillled with user information """ return self._keycloak.userinfo(access_token) # stores instances of KeycloakOpenIDConnect class # dict keys are (realm_name, client_id) tuples _keycloak_oidc: Dict[Tuple[str, str], KeycloakOpenIDConnect] = {} def get_keycloak_oidc_client( server_url: str, realm_name: str, client_id: str ) -> KeycloakOpenIDConnect: """ Instantiate a KeycloakOpenIDConnect class for a given client in a given realm. Args: server_url: Base URL of a Keycloak server realm_name: Name of the realm in Keycloak client_id: Client identifier in the realm Returns: An object to ease the interaction with the Keycloak server """ realm_client_key = (realm_name, client_id) if realm_client_key not in _keycloak_oidc: _keycloak_oidc[realm_client_key] = KeycloakOpenIDConnect( server_url, realm_name, client_id ) return _keycloak_oidc[realm_client_key] diff --git a/swh/web/auth/migrations/0001_initial.py b/swh/web/auth/migrations/0001_initial.py new file mode 100644 index 00000000..fee14893 --- /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 similarity index 100% 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 index 94bf8da4..ee9a9fbe 100644 --- a/swh/web/auth/models.py +++ b/swh/web/auth/models.py @@ -1,80 +1,95 @@ # 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 datetime import datetime from typing import Optional, Set from django.contrib.auth.models import User +from django.db import models class OIDCUser(User): """ Custom User proxy model for remote users storing OpenID Connect related data: profile containing authentication tokens. The model is also not saved to database as all users are already stored in the Keycloak one. """ # OIDC subject identifier sub: str = "" # OIDC tokens and session related data, only relevant when a user # authenticates from a web browser access_token: Optional[str] = None expires_at: Optional[datetime] = None id_token: Optional[str] = None refresh_token: Optional[str] = None refresh_expires_at: Optional[datetime] = None scope: Optional[str] = None session_state: Optional[str] = None # User permissions permissions: Set[str] class Meta: app_label = "swh.web.auth" proxy = True def save(self, **kwargs): """ Override django.db.models.Model.save to avoid saving the remote users to web application database. """ pass def get_group_permissions(self, obj=None) -> Set[str]: """ Override django.contrib.auth.models.PermissionsMixin.get_group_permissions to get permissions from OIDC """ return self.get_all_permissions(obj) def get_all_permissions(self, obj=None) -> Set[str]: """ Override django.contrib.auth.models.PermissionsMixin.get_all_permissions to get permissions from OIDC """ return self.permissions def has_perm(self, perm, obj=None) -> bool: """ Override django.contrib.auth.models.PermissionsMixin.has_perm to check permission from OIDC """ if self.is_active and self.is_superuser: return True return perm in self.permissions def has_module_perms(self, app_label) -> bool: """ Override django.contrib.auth.models.PermissionsMixin.has_module_perms to check permissions from OIDC. """ if self.is_active and self.is_superuser: 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 index c4a056cf..54c6dcbb 100644 --- a/swh/web/auth/utils.py +++ b/swh/web/auth/utils.py @@ -1,60 +1,122 @@ # 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 base64 import urlsafe_b64encode import hashlib 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 def gen_oidc_pkce_codes() -> Tuple[str, str]: """ Generates a code verifier and a code challenge to be used with the OpenID Connect authorization code flow with PKCE ("Proof Key for Code Exchange", see https://tools.ietf.org/html/rfc7636). PKCE replaces the static secret used in the standard authorization code flow with a temporary one-time challenge, making it feasible to use in public clients. The implementation is inspired from that blog post: https://www.stefaanlippens.net/oauth-code-flow-pkce.html """ # generate a code verifier which is a long enough random alphanumeric # string, only to be used "client side" code_verifier_str = secrets.token_urlsafe(60) # create the PKCE code challenge by hashing the code verifier with SHA256 # and encoding the result in URL-safe base64 (without padding) code_challenge = hashlib.sha256(code_verifier_str.encode("ascii")).digest() code_challenge_str = urlsafe_b64encode(code_challenge).decode("ascii") code_challenge_str = code_challenge_str.replace("=", "") return code_verifier_str, code_challenge_str 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 SoftwareHeritage realm. Args: client_id: client identifier in the SoftwareHeritage realm Returns: An object to ease the interaction with the Keycloak server """ swhweb_config = get_config() return get_keycloak_oidc_client( swhweb_config["keycloak"]["server_url"], swhweb_config["keycloak"]["realm_name"], client_id, ) diff --git a/swh/web/auth/views.py b/swh/web/auth/views.py index 2f984950..c89e82b1 100644 --- a/swh/web/auth/views.py +++ b/swh/web/auth/views.py @@ -1,136 +1,261 @@ # 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 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 def oidc_login(request: HttpRequest) -> HttpResponse: """ Django view to initiate login process using OpenID Connect. """ # generate a CSRF token state = str(uuid.uuid4()) redirect_uri = reverse("oidc-login-complete", request=request) code_verifier, code_challenge = gen_oidc_pkce_codes() request.session["login_data"] = { "code_verifier": code_verifier, "state": state, "redirect_uri": redirect_uri, "next_path": request.GET.get("next_path", ""), "prompt": request.GET.get("prompt", ""), } authorization_url_params = { "state": state, "code_challenge": code_challenge, "code_challenge_method": "S256", "scope": "openid", "prompt": request.GET.get("prompt", ""), } try: oidc_client = get_oidc_client() authorization_url = oidc_client.authorization_url( redirect_uri, **authorization_url_params ) return HttpResponseRedirect(authorization_url) except Exception as e: return handle_view_exception(request, e) def oidc_login_complete(request: HttpRequest) -> HttpResponse: """ Django view to finalize login process using OpenID Connect. """ try: if "login_data" not in request.session: raise Exception("Login process has not been initialized.") login_data = request.session["login_data"] next_path = login_data["next_path"] or request.build_absolute_uri("/") if "error" in request.GET: if login_data["prompt"] == "none": # Silent login failed because OIDC session expired. # Redirect to logout page and inform user. logout(request) logout_url = reverse( "logout", query_params={"next_path": next_path, "remote_user": 1} ) return HttpResponseRedirect(logout_url) return HttpResponseServerError(request.GET["error"]) if "code" not in request.GET or "state" not in request.GET: raise BadInputExc("Missing query parameters for authentication.") # get CSRF token returned by OIDC server state = request.GET["state"] if state != login_data["state"]: raise BadInputExc("Wrong CSRF token, aborting login process.") user = authenticate( request=request, code=request.GET["code"], code_verifier=login_data["code_verifier"], redirect_uri=login_data["redirect_uri"], ) if user is None: raise Exception("User authentication failed.") login(request, user) return HttpResponseRedirect(next_path) except Exception as e: return handle_view_exception(request, e) def oidc_logout(request: HttpRequest) -> HttpResponse: """ Django view to logout using OpenID Connect. """ try: user = request.user logout(request) if hasattr(user, "refresh_token"): oidc_client = get_oidc_client() user = cast(OIDCUser, user) refresh_token = cast(str, user.refresh_token) # end OpenID Connect session oidc_client.logout(refresh_token) # remove user data from cache cache.delete(f"oidc_user_{user.id}") logout_url = reverse("logout", query_params={"remote_user": 1}) return HttpResponseRedirect(request.build_absolute_uri(logout_url)) except Exception as e: 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 index 6b7a2b72..21b66414 100644 --- a/swh/web/settings/common.py +++ b/swh/web/settings/common.py @@ -1,283 +1,284 @@ # Copyright (C) 2017-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 """ Django common settings for swh-web. """ import os import sys from typing import Any, Dict from swh.web.config import get_config swh_web_config = get_config() # Build paths inside the project like this: os.path.join(BASE_DIR, ...) PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = swh_web_config["secret_key"] # SECURITY WARNING: don't run with debug turned on in production! DEBUG = swh_web_config["debug"] DEBUG_PROPAGATE_EXCEPTIONS = swh_web_config["debug"] ALLOWED_HOSTS = ["127.0.0.1", "localhost"] + swh_web_config["allowed_hosts"] # Application definition INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", "swh.web.common", "swh.web.api", + "swh.web.auth", "swh.web.browse", "webpack_loader", "django_js_reverse", "corsheaders", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "swh.web.auth.middlewares.OIDCSessionRefreshMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "swh.web.common.middlewares.ThrottlingHeadersMiddleware", ] # Compress all assets (static ones and dynamically generated html) # served by django in a local development environment context. # In a production environment, assets compression will be directly # handled by web servers like apache or nginx. if swh_web_config["serve_assets"]: MIDDLEWARE.insert(0, "django.middleware.gzip.GZipMiddleware") ROOT_URLCONF = "swh.web.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [os.path.join(PROJECT_DIR, "../templates")], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "swh.web.common.utils.context_processor", ], "libraries": {"swh_templatetags": "swh.web.common.swh_templatetags",}, }, }, ] DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": swh_web_config["development_db"], } } # Password validation # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # noqa }, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",}, ] # Internationalization # https://docs.djangoproject.com/en/1.11/topics/i18n/ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True USE_L10N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.11/howto/static-files/ STATIC_URL = "/static/" # static folder location when swh-web has been installed with pip STATIC_DIR = os.path.join(sys.prefix, "share/swh/web/static") if not os.path.exists(STATIC_DIR): # static folder location when developping swh-web STATIC_DIR = os.path.join(PROJECT_DIR, "../../../static") STATICFILES_DIRS = [STATIC_DIR] INTERNAL_IPS = ["127.0.0.1"] throttle_rates = {} http_requests = ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"] throttling = swh_web_config["throttling"] for limiter_scope, limiter_conf in throttling["scopes"].items(): if "default" in limiter_conf["limiter_rate"]: throttle_rates[limiter_scope] = limiter_conf["limiter_rate"]["default"] # for backward compatibility else: throttle_rates[limiter_scope] = limiter_conf["limiter_rate"] # register sub scopes specific for HTTP request types for http_request in http_requests: if http_request in limiter_conf["limiter_rate"]: throttle_rates[limiter_scope + "_" + http_request.lower()] = limiter_conf[ "limiter_rate" ][http_request] REST_FRAMEWORK: Dict[str, Any] = { "DEFAULT_RENDERER_CLASSES": ( "rest_framework.renderers.JSONRenderer", "swh.web.api.renderers.YAMLRenderer", "rest_framework.renderers.TemplateHTMLRenderer", ), "DEFAULT_THROTTLE_CLASSES": ("swh.web.api.throttling.SwhWebRateThrottle",), "DEFAULT_THROTTLE_RATES": throttle_rates, "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.SessionAuthentication", "swh.web.auth.backends.OIDCBearerTokenAuthentication", ], } LOGGING = { "version": 1, "disable_existing_loggers": False, "filters": { "require_debug_false": {"()": "django.utils.log.RequireDebugFalse",}, "require_debug_true": {"()": "django.utils.log.RequireDebugTrue",}, }, "formatters": { "request": { "format": "[%(asctime)s] [%(levelname)s] %(request)s %(status_code)s", "datefmt": "%d/%b/%Y %H:%M:%S", }, "simple": { "format": "[%(asctime)s] [%(levelname)s] %(message)s", "datefmt": "%d/%b/%Y %H:%M:%S", }, "verbose": { "format": ( "[%(asctime)s] [%(levelname)s] %(name)s.%(funcName)s:%(lineno)s " "- %(message)s" ), "datefmt": "%d/%b/%Y %H:%M:%S", }, }, "handlers": { "console": { "level": "DEBUG", "filters": ["require_debug_true"], "class": "logging.StreamHandler", "formatter": "simple", }, "file": { "level": "WARNING", "filters": ["require_debug_false"], "class": "logging.FileHandler", "filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"), "formatter": "simple", }, "file_request": { "level": "WARNING", "filters": ["require_debug_false"], "class": "logging.FileHandler", "filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"), "formatter": "request", }, "console_verbose": { "level": "DEBUG", "filters": ["require_debug_true"], "class": "logging.StreamHandler", "formatter": "verbose", }, "file_verbose": { "level": "WARNING", "filters": ["require_debug_false"], "class": "logging.FileHandler", "filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"), "formatter": "verbose", }, "null": {"class": "logging.NullHandler",}, }, "loggers": { "": { "handlers": ["console_verbose", "file_verbose"], "level": "DEBUG" if DEBUG else "WARNING", }, "django": { "handlers": ["console"], "level": "DEBUG" if DEBUG else "WARNING", "propagate": False, }, "django.request": { "handlers": ["file_request"], "level": "DEBUG" if DEBUG else "WARNING", "propagate": False, }, "django.db.backends": {"handlers": ["null"], "propagate": False}, "django.utils.autoreload": {"level": "INFO",}, }, } WEBPACK_LOADER = { "DEFAULT": { "CACHE": False, "BUNDLE_DIR_NAME": "./", "STATS_FILE": os.path.join(STATIC_DIR, "webpack-stats.json"), "POLL_INTERVAL": 0.1, "TIMEOUT": None, "IGNORE": [".+\\.hot-update.js", ".+\\.map"], } } LOGIN_URL = "/admin/login/" LOGIN_REDIRECT_URL = "admin" SESSION_ENGINE = "django.contrib.sessions.backends.cache" CACHES = { "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, "db_cache": { "BACKEND": "django.core.cache.backends.db.DatabaseCache", "LOCATION": "swh_web_cache", }, } JS_REVERSE_JS_MINIFY = False CORS_ORIGIN_ALLOW_ALL = True CORS_URLS_REGEX = r"^/badge/.*$" AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", "swh.web.auth.backends.OIDCAuthorizationCodePKCEBackend", ] diff --git a/swh/web/templates/api/tokens.html b/swh/web/templates/api/tokens.html new file mode 100644 index 00000000..19ad64cd --- /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 | +
---|