Page MenuHomeSoftware Heritage

D3996.diff
No OneTemporary

D3996.diff

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 =
+ `<form id="swh-password-form">
+ <p id="swh-password-form-text">${infoText}</p>
+ <label for="swh-user-password">Password:&nbsp;</label>
+ <input id="swh-user-password" type="password" name="swh-user-password" required>
+ <input id="swh-user-password-submit" type="submit" value="${buttonText}" disabled>
+ </form>`;
+ return form;
+}
+
+function errorMessage(message) {
+ return `<p id="swh-token-error-message" class="mt-3">${message}</p>`;
+}
+
+function successMessage(message) {
+ return `<p id="swh-token-success-message" class="mt-3">${message}</p>`;
+}
+
+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.')}
+ <pre id="swh-bearer-token">${token}</pre>`;
+ $(`#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.')}
+ <pre id="swh-bearer-token">${token}</pre>`;
+ $(`#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 =
+ `<button class="btn btn-default"
+ onclick="swh.auth.applyTokenAction('display', ${row.id})">
+ Display token
+ </button>
+ <button class="btn btn-default"
+ onclick="swh.auth.applyTokenAction('revoke', ${row.id})">
+ Revoke token
+ </button>`;
+ 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 &ndash; Software Heritage API {% endblock %}
+
+{% block header %}
+{% render_bundle 'auth' %}
+{% endblock %}
+
+{% block navbar-content %}
+<h4>Web API bearer tokens management</h4>
+{% endblock %}
+
+{% block content %}
+
+<p>
+ 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.
+</p>
+<p>
+ For instance when using <code>curl</code> proceed as follows:
+ <pre>curl -H "Authorization: Bearer ${TOKEN}" {{ request.scheme }}://{{ request.META.HTTP_HOST }}/api/...</pre>
+</p>
+
+<div class="mt-3">
+ <div class="float-right">
+ <button class="btn btn-default" onclick="swh.auth.applyTokenAction('generate')">
+ Generate new token
+ </button>
+ <button class="btn btn-default float-right" onclick="swh.auth.applyTokenAction('revokeAll')">
+ Revoke all tokens
+ </button>
+ </div>
+ <table id="swh-bearer-tokens-table" class="table swh-table swh-table-striped" width="100%">
+ <thead>
+ <tr>
+ <th>Creation date</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ </table>
+</div>
+
+<script>
+ swh.auth.initApiTokensPage();
+</script>
+
+{% 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

File Metadata

Mime Type
text/plain
Expires
Wed, Sep 17, 4:55 PM (21 h, 53 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3216838

Event Timeline