Page Menu
Home
Software Heritage
Search
Configure Global Search
Log In
Files
F11023655
D3996.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
39 KB
Subscribers
None
D3996.diff
View Options
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: </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 – 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
Details
Attached
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
Attached To
D3996: auth: Add web UI for API bearer tokens management
Event Timeline
Log In to Comment