0001-general-remove-fargo.oauth2-67570.patch
fargo/fargo/managers.py | ||
---|---|---|
28 | 28 |
n = n or now() |
29 | 29 |
# use a window of 60 seconds to be sure this document will never be used |
30 | 30 |
qs = self.filter(creation_date__lt=n - datetime.timedelta(seconds=60)) |
31 |
qs = qs.filter(user_documents__isnull=True, oauth2_tempfiles__isnull=True)
|
|
31 |
qs = qs.filter(user_documents__isnull=True) |
|
32 | 32 |
for document in qs: |
33 | 33 |
document.content.delete(False) |
34 | 34 |
qs.delete() |
fargo/oauth2/admin.py | ||
---|---|---|
1 |
# fargo - document box |
|
2 |
# Copyright (C) 2016-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.contrib import admin |
|
18 |
from django.utils.translation import ugettext_lazy as _ |
|
19 | ||
20 |
from .models import OAuth2Authorize, OAuth2Client, OAuth2TempFile |
|
21 | ||
22 | ||
23 |
class OAuth2ClientAdmin(admin.ModelAdmin): |
|
24 |
fields = ('client_name', 'client_id', 'client_secret', 'redirect_uris') |
|
25 |
list_display = ['client_name', 'client_id', 'client_secret', 'redirect_uris'] |
|
26 | ||
27 | ||
28 |
class OAuth2AuthorizeAdmin(admin.ModelAdmin): |
|
29 |
list_display = [ |
|
30 |
'id', |
|
31 |
'client_name', |
|
32 |
'user_document', |
|
33 |
'thumbnail', |
|
34 |
'access_token', |
|
35 |
'code', |
|
36 |
'creation_date', |
|
37 |
] |
|
38 |
raw_id_fields = ['user_document'] |
|
39 |
search_fields = [ |
|
40 |
'client__client_name', |
|
41 |
'user_document__user__email', |
|
42 |
'user_document__user__first_name', |
|
43 |
'user_document__user__last_name', |
|
44 |
'user_document__filename', |
|
45 |
'user_document__user__contenat_has', |
|
46 |
] |
|
47 | ||
48 |
def thumbnail(self, instance): |
|
49 |
return instance.user_document.document.thumbnail_img_tag |
|
50 | ||
51 |
thumbnail.short_description = _('thumbnail') |
|
52 | ||
53 |
def client_name(self, instance): |
|
54 |
return instance.client.client_name |
|
55 | ||
56 | ||
57 |
class OAuth2TempFileAdmin(admin.ModelAdmin): |
|
58 |
list_display = ['uuid', 'client_name', 'filename', 'thumbnail', 'creation_date'] |
|
59 |
raw_id_fields = ['document'] |
|
60 |
search_fields = ['filename', 'uuid', 'client__client_name'] |
|
61 | ||
62 |
def thumbnail(self, instance): |
|
63 |
return instance.document.thumbnail_img_tag |
|
64 | ||
65 |
thumbnail.short_description = _('thumbnail') |
|
66 | ||
67 |
def client_name(self, instance): |
|
68 |
return instance.client.client_name |
|
69 | ||
70 | ||
71 |
admin.site.register(OAuth2Client, OAuth2ClientAdmin) |
|
72 |
admin.site.register(OAuth2Authorize, OAuth2AuthorizeAdmin) |
|
73 |
admin.site.register(OAuth2TempFile, OAuth2TempFileAdmin) |
fargo/oauth2/authentication.py | ||
---|---|---|
1 |
# fargo - document box |
|
2 |
# Copyright (C) 2016-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import logging |
|
18 | ||
19 |
import requests |
|
20 |
from django.conf import settings |
|
21 |
from django.utils.six.moves.urllib import parse as urlparse |
|
22 |
from django.utils.translation import ugettext_lazy as _ |
|
23 |
from rest_framework.authentication import BasicAuthentication |
|
24 |
from rest_framework.exceptions import AuthenticationFailed |
|
25 | ||
26 |
from .models import OAuth2Client |
|
27 | ||
28 | ||
29 |
class OAuth2User: |
|
30 |
"""Fake user class to return in case OAuth2 Client authentication""" |
|
31 | ||
32 |
def __init__(self, oauth2_client): |
|
33 |
self.oauth2_client = oauth2_client |
|
34 |
self.authenticated = False |
|
35 | ||
36 |
def has_perm(self, *args, **kwargs): |
|
37 |
return True |
|
38 | ||
39 |
def has_perm_any(self, *args, **kwargs): |
|
40 |
return True |
|
41 | ||
42 |
def has_ou_perm(self, *args, **kwargs): |
|
43 |
return True |
|
44 | ||
45 |
def filter_by_perm(self, perms, queryset): |
|
46 |
return queryset |
|
47 | ||
48 |
def is_authenticated(self): |
|
49 |
return self.authenticated |
|
50 | ||
51 |
def is_staff(self): |
|
52 |
return False |
|
53 | ||
54 | ||
55 |
class FargoOAUTH2Authentication(BasicAuthentication): |
|
56 |
def authenticate_through_idp(self, client_id, client_secret): |
|
57 |
"""Check client_id and client_secret with configured IdP, and verify it is an OIDC |
|
58 |
client. |
|
59 |
""" |
|
60 |
logger = logging.getLogger(__name__) |
|
61 | ||
62 |
authentic_idp = getattr(settings, 'FARGO_IDP_URL', None) |
|
63 |
if not authentic_idp: |
|
64 |
logger.warning('idp check-password not configured') |
|
65 |
return False, '' |
|
66 | ||
67 |
url = urlparse.urljoin(authentic_idp, 'api/check-password/') |
|
68 |
try: |
|
69 |
response = requests.post( |
|
70 |
url, |
|
71 |
json={'username': client_id, 'password': client_secret}, |
|
72 |
auth=(client_id, client_secret), |
|
73 |
verify=False, |
|
74 |
) |
|
75 |
response.raise_for_status() |
|
76 |
except requests.RequestException as e: |
|
77 |
logger.warning('idp check-password API failed: %s', e) |
|
78 |
return False, 'idp is down' |
|
79 |
try: |
|
80 |
response = response.json() |
|
81 |
except ValueError as e: |
|
82 |
logger.warning('idp check-password API failed: %s, %r', e, response.content) |
|
83 |
return False, 'idp is down' |
|
84 | ||
85 |
if response.get('result') == 0: |
|
86 |
logger.warning('idp check-password API failed') |
|
87 |
return False, response.get('errors', [''])[0] |
|
88 | ||
89 |
return True, None |
|
90 | ||
91 |
def authenticate_credentials(self, client_id, client_secret, request=None): |
|
92 |
try: |
|
93 |
client = OAuth2Client.objects.get(client_id=client_id, client_secret=client_secret) |
|
94 |
except OAuth2Client.DoesNotExist: |
|
95 |
success, error = self.authenticate_through_idp(client_id, client_secret) |
|
96 |
if not success: |
|
97 |
raise AuthenticationFailed(error or _('Invalid client_id/client_secret.')) |
|
98 |
client = OAuth2Client.objects.get(client_id=client_id) |
|
99 |
client.client_secret = client_secret |
|
100 |
client.save() |
|
101 | ||
102 |
user = OAuth2User(client) |
|
103 |
user.authenticated = True |
|
104 |
return user, True |
fargo/oauth2/forms.py | ||
---|---|---|
1 |
# fargo - document box |
|
2 |
# Copyright (C) 2016-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django import forms |
|
18 |
from django.utils.translation import ugettext_lazy as _ |
|
19 | ||
20 |
from fargo.fargo.models import UserDocument |
|
21 | ||
22 | ||
23 |
class UserDocModelChoiceField(forms.ModelChoiceField): |
|
24 |
def label_from_instance(self, obj): |
|
25 |
return obj.filename |
|
26 | ||
27 | ||
28 |
class OAuth2AuthorizeForm(forms.Form): |
|
29 |
document = UserDocModelChoiceField(queryset=None) |
|
30 | ||
31 |
def __init__(self, user, *args, **kwargs): |
|
32 |
super().__init__(*args, **kwargs) |
|
33 |
self.fields['document'].queryset = UserDocument.objects.filter(user=user) |
|
34 |
self.fields['document'].label = _('Document') |
fargo/oauth2/management/commands/oauth2-create-client.py | ||
---|---|---|
1 |
# fargo - document box |
|
2 |
# Copyright (C) 2016-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.core.management.base import BaseCommand |
|
18 | ||
19 |
from fargo.oauth2.models import OAuth2Client |
|
20 | ||
21 | ||
22 |
class Command(BaseCommand): |
|
23 |
help = 'Create an OAuth2 client' |
|
24 | ||
25 |
def add_arguments(self, parser): |
|
26 |
parser.add_argument('client_name') |
|
27 |
parser.add_argument('redirect_uris') |
|
28 |
parser.add_argument('--client-id', required=False, default=None) |
|
29 |
parser.add_argument('--client-secret', required=False, default=None) |
|
30 | ||
31 |
def handle(self, client_name, redirect_uris, client_id, client_secret, **options): |
|
32 |
kwargs = { |
|
33 |
'client_name': client_name, |
|
34 |
'redirect_uris': redirect_uris, |
|
35 |
} |
|
36 |
if client_id: |
|
37 |
kwargs['client_id'] = client_id |
|
38 |
if client_secret: |
|
39 |
kwargs['client_secret'] = client_secret |
|
40 |
OAuth2Client.objects.create(**kwargs) |
fargo/oauth2/management/commands/oauth2-put-document.py | ||
---|---|---|
1 |
# fargo - document box |
|
2 |
# Copyright (C) 2016-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import os |
|
18 | ||
19 |
from django.core.files.base import ContentFile |
|
20 |
from django.core.management.base import BaseCommand |
|
21 |
from django.urls import reverse |
|
22 | ||
23 |
from fargo.fargo.models import Document |
|
24 |
from fargo.oauth2.models import OAuth2Client, OAuth2TempFile |
|
25 |
from fargo.utils import make_url |
|
26 | ||
27 | ||
28 |
class Command(BaseCommand): |
|
29 |
help = 'Push documents inside fargo, returns URLs' |
|
30 | ||
31 |
def add_arguments(self, parser): |
|
32 |
parser.add_argument('--client-id', type=int) |
|
33 |
parser.add_argument('redirect_uri') |
|
34 |
parser.add_argument('paths', nargs='+') |
|
35 | ||
36 |
def handle(self, redirect_uri, paths, client_id, **options): |
|
37 |
client = OAuth2Client.objects.get(id=client_id) |
|
38 |
for path in paths: |
|
39 |
with open(path, 'rb') as file_object: |
|
40 |
filename = os.path.basename(path) |
|
41 |
f = ContentFile(file_object.read(), name=filename) |
|
42 |
document = Document.objects.get_by_file(f) |
|
43 |
oauth2_document = OAuth2TempFile.objects.create( |
|
44 |
client=client, document=document, filename=filename |
|
45 |
) |
|
46 |
uri = reverse('oauth2-put-document-authorize', args=[oauth2_document.pk]) |
|
47 |
self.stdout.write('https://localhost:8000' + make_url(uri, redirect_uri=redirect_uri)) |
fargo/oauth2/migrations/0001_initial.py | ||
---|---|---|
1 |
from django.db import migrations, models |
|
2 | ||
3 |
import fargo.oauth2.models |
|
4 | ||
5 | ||
6 |
class Migration(migrations.Migration): |
|
7 | ||
8 |
dependencies = [ |
|
9 |
('fargo', '0013_document_mime_type'), |
|
10 |
] |
|
11 | ||
12 |
operations = [ |
|
13 |
migrations.CreateModel( |
|
14 |
name='OAuth2Authorize', |
|
15 |
fields=[ |
|
16 |
( |
|
17 |
'id', |
|
18 |
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True), |
|
19 |
), |
|
20 |
('access_token', models.CharField(default=fargo.oauth2.models.generate_uuid, max_length=255)), |
|
21 |
('code', models.CharField(default=fargo.oauth2.models.generate_uuid, max_length=255)), |
|
22 |
('creation_date', models.DateTimeField(auto_now=True)), |
|
23 |
('user_document', models.ForeignKey(to='fargo.UserDocument', on_delete=models.CASCADE)), |
|
24 |
], |
|
25 |
), |
|
26 |
migrations.CreateModel( |
|
27 |
name='OAuth2Client', |
|
28 |
fields=[ |
|
29 |
( |
|
30 |
'id', |
|
31 |
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True), |
|
32 |
), |
|
33 |
( |
|
34 |
'client_secret', |
|
35 |
models.CharField(default=fargo.oauth2.models.generate_uuid, max_length=255), |
|
36 |
), |
|
37 |
('client_id', models.CharField(default=fargo.oauth2.models.generate_uuid, max_length=255)), |
|
38 |
('client_name', models.CharField(max_length=255)), |
|
39 |
( |
|
40 |
'redirect_uris', |
|
41 |
models.TextField( |
|
42 |
verbose_name='redirect URIs', validators=[fargo.oauth2.models.validate_https_url] |
|
43 |
), |
|
44 |
), |
|
45 |
], |
|
46 |
), |
|
47 |
migrations.CreateModel( |
|
48 |
name='OAuth2TempFile', |
|
49 |
fields=[ |
|
50 |
('hash_key', models.CharField(max_length=128, serialize=False, primary_key=True)), |
|
51 |
('filename', models.CharField(max_length=512)), |
|
52 |
( |
|
53 |
'document', |
|
54 |
models.ForeignKey( |
|
55 |
to='fargo.Document', related_name='oauth2_tempfiles', on_delete=models.CASCADE |
|
56 |
), |
|
57 |
), |
|
58 |
], |
|
59 |
), |
|
60 |
] |
fargo/oauth2/migrations/0001_squashed_0005_auto_20180331_1532.py | ||
---|---|---|
1 |
# Generated by Django 1.11.11 on 2018-03-31 13:36 |
|
2 | ||
3 |
import django.db.models.deletion |
|
4 |
from django.db import migrations, models |
|
5 | ||
6 |
import fargo.oauth2.models |
|
7 | ||
8 | ||
9 |
class Migration(migrations.Migration): |
|
10 | ||
11 |
replaces = [ |
|
12 |
('oauth2', '0001_initial'), |
|
13 |
('oauth2', '0002_auto_20180321_2343'), |
|
14 |
('oauth2', '0003_auto_20180322_1016'), |
|
15 |
('oauth2', '0004_auto_20180326_1330'), |
|
16 |
('oauth2', '0005_auto_20180331_1532'), |
|
17 |
] |
|
18 | ||
19 |
initial = True |
|
20 | ||
21 |
dependencies = [ |
|
22 |
('fargo', '0013_document_mime_type'), |
|
23 |
] |
|
24 | ||
25 |
operations = [ |
|
26 |
migrations.CreateModel( |
|
27 |
name='OAuth2Authorize', |
|
28 |
fields=[ |
|
29 |
( |
|
30 |
'id', |
|
31 |
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True), |
|
32 |
), |
|
33 |
('access_token', models.CharField(default=fargo.oauth2.models.generate_uuid, max_length=255)), |
|
34 |
('code', models.CharField(default=fargo.oauth2.models.generate_uuid, max_length=255)), |
|
35 |
('creation_date', models.DateTimeField(auto_now_add=True)), |
|
36 |
], |
|
37 |
options={ |
|
38 |
'ordering': ('creation_date',), |
|
39 |
'verbose_name': 'OAUTH2 authorization', |
|
40 |
'verbose_name_plural': 'OAUTH2 authorizations', |
|
41 |
}, |
|
42 |
), |
|
43 |
migrations.CreateModel( |
|
44 |
name='OAuth2Client', |
|
45 |
fields=[ |
|
46 |
( |
|
47 |
'id', |
|
48 |
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True), |
|
49 |
), |
|
50 |
('client_name', models.CharField(max_length=255)), |
|
51 |
( |
|
52 |
'redirect_uris', |
|
53 |
models.TextField( |
|
54 |
verbose_name='redirect URIs', validators=[fargo.oauth2.models.validate_https_url] |
|
55 |
), |
|
56 |
), |
|
57 |
('client_id', models.CharField(default=fargo.oauth2.models.generate_uuid, max_length=255)), |
|
58 |
( |
|
59 |
'client_secret', |
|
60 |
models.CharField(default=fargo.oauth2.models.generate_uuid, max_length=255), |
|
61 |
), |
|
62 |
], |
|
63 |
options={ |
|
64 |
'ordering': ('client_name',), |
|
65 |
'verbose_name': 'OAUTH2 client', |
|
66 |
'verbose_name_plural': 'OAUTH2 clients', |
|
67 |
}, |
|
68 |
), |
|
69 |
migrations.CreateModel( |
|
70 |
name='OAuth2TempFile', |
|
71 |
fields=[ |
|
72 |
( |
|
73 |
'uuid', |
|
74 |
models.CharField( |
|
75 |
default=fargo.oauth2.models.generate_uuid, |
|
76 |
max_length=32, |
|
77 |
serialize=False, |
|
78 |
primary_key=True, |
|
79 |
), |
|
80 |
), |
|
81 |
('filename', models.CharField(max_length=512)), |
|
82 |
('creation_date', models.DateTimeField(auto_now_add=True)), |
|
83 |
('client', models.ForeignKey(to='oauth2.OAuth2Client', on_delete=models.CASCADE)), |
|
84 |
( |
|
85 |
'document', |
|
86 |
models.ForeignKey( |
|
87 |
related_name='oauth2_tempfiles', to='fargo.Document', on_delete=models.CASCADE |
|
88 |
), |
|
89 |
), |
|
90 |
], |
|
91 |
options={ |
|
92 |
'ordering': ('creation_date',), |
|
93 |
'verbose_name': 'OAUTH2 temporary file', |
|
94 |
'verbose_name_plural': 'OAUTH2 temporary files', |
|
95 |
}, |
|
96 |
), |
|
97 |
migrations.AddField( |
|
98 |
model_name='oauth2authorize', |
|
99 |
name='client', |
|
100 |
field=models.ForeignKey(to='oauth2.OAuth2Client', on_delete=models.CASCADE), |
|
101 |
), |
|
102 |
migrations.AddField( |
|
103 |
model_name='oauth2authorize', |
|
104 |
name='user_document', |
|
105 |
field=models.ForeignKey(to='fargo.UserDocument', on_delete=models.CASCADE), |
|
106 |
), |
|
107 |
] |
fargo/oauth2/migrations/0002_auto_20180321_2343.py | ||
---|---|---|
1 |
# Generated by Django 1.11.11 on 2018-03-21 23:43 |
|
2 | ||
3 |
import django.db.models.deletion |
|
4 |
from django.db import migrations, models |
|
5 | ||
6 | ||
7 |
def delete_all_client_linked_models(apps, schema_editor): |
|
8 |
OAuth2Authorize = apps.get_model('oauth2', 'OAuth2Authorize') |
|
9 |
OAuth2TempFile = apps.get_model('oauth2', 'OAuth2TempFile') |
|
10 |
OAuth2Authorize.objects.all().delete() |
|
11 |
OAuth2TempFile.objects.all().delete() |
|
12 | ||
13 | ||
14 |
def noop(apps, schema_editor): |
|
15 |
pass |
|
16 | ||
17 | ||
18 |
class Migration(migrations.Migration): |
|
19 | ||
20 |
dependencies = [ |
|
21 |
('oauth2', '0001_initial'), |
|
22 |
] |
|
23 | ||
24 |
operations = [ |
|
25 |
migrations.RunPython(delete_all_client_linked_models, noop), |
|
26 |
migrations.AddField( |
|
27 |
model_name='oauth2authorize', |
|
28 |
name='client', |
|
29 |
field=models.ForeignKey( |
|
30 |
default=1, on_delete=django.db.models.deletion.CASCADE, to='oauth2.OAuth2Client' |
|
31 |
), |
|
32 |
preserve_default=False, |
|
33 |
), |
|
34 |
migrations.AddField( |
|
35 |
model_name='oauth2tempfile', |
|
36 |
name='client', |
|
37 |
field=models.ForeignKey( |
|
38 |
default=1, on_delete=django.db.models.deletion.CASCADE, to='oauth2.OAuth2Client' |
|
39 |
), |
|
40 |
preserve_default=False, |
|
41 |
), |
|
42 |
] |
fargo/oauth2/migrations/0003_auto_20180322_1016.py | ||
---|---|---|
1 |
# Generated by Django 1.11.11 on 2018-03-22 10:16 |
|
2 | ||
3 |
from django.db import migrations, models |
|
4 | ||
5 |
import fargo.oauth2.models |
|
6 | ||
7 | ||
8 |
class Migration(migrations.Migration): |
|
9 | ||
10 |
dependencies = [ |
|
11 |
('oauth2', '0002_auto_20180321_2343'), |
|
12 |
] |
|
13 | ||
14 |
operations = [ |
|
15 |
migrations.RemoveField( |
|
16 |
model_name='oauth2tempfile', |
|
17 |
name='hash_key', |
|
18 |
), |
|
19 |
migrations.AddField( |
|
20 |
model_name='oauth2tempfile', |
|
21 |
name='creation_date', |
|
22 |
field=models.DateTimeField(auto_now=True), |
|
23 |
), |
|
24 |
migrations.AddField( |
|
25 |
model_name='oauth2tempfile', |
|
26 |
name='uuid', |
|
27 |
field=models.CharField( |
|
28 |
default=fargo.oauth2.models.generate_uuid, max_length=32, primary_key=True, serialize=False |
|
29 |
), |
|
30 |
), |
|
31 |
] |
fargo/oauth2/migrations/0004_auto_20180326_1330.py | ||
---|---|---|
1 |
# Generated by Django 1.11.11 on 2018-03-26 13:30 |
|
2 | ||
3 |
from django.db import migrations, models |
|
4 | ||
5 | ||
6 |
class Migration(migrations.Migration): |
|
7 | ||
8 |
dependencies = [ |
|
9 |
('oauth2', '0003_auto_20180322_1016'), |
|
10 |
] |
|
11 | ||
12 |
operations = [ |
|
13 |
migrations.AlterField( |
|
14 |
model_name='oauth2authorize', |
|
15 |
name='creation_date', |
|
16 |
field=models.DateTimeField(auto_now_add=True), |
|
17 |
), |
|
18 |
migrations.AlterField( |
|
19 |
model_name='oauth2tempfile', |
|
20 |
name='creation_date', |
|
21 |
field=models.DateTimeField(auto_now_add=True), |
|
22 |
), |
|
23 |
] |
fargo/oauth2/migrations/0005_auto_20180331_1532.py | ||
---|---|---|
1 |
# Generated by Django 1.11.11 on 2018-03-31 13:32 |
|
2 | ||
3 |
from django.db import migrations |
|
4 | ||
5 | ||
6 |
class Migration(migrations.Migration): |
|
7 | ||
8 |
dependencies = [ |
|
9 |
('oauth2', '0004_auto_20180326_1330'), |
|
10 |
] |
|
11 | ||
12 |
operations = [ |
|
13 |
migrations.AlterModelOptions( |
|
14 |
name='oauth2authorize', |
|
15 |
options={ |
|
16 |
'ordering': ('creation_date',), |
|
17 |
'verbose_name': 'OAUTH2 authorization', |
|
18 |
'verbose_name_plural': 'OAUTH2 authorizations', |
|
19 |
}, |
|
20 |
), |
|
21 |
migrations.AlterModelOptions( |
|
22 |
name='oauth2client', |
|
23 |
options={ |
|
24 |
'ordering': ('client_name',), |
|
25 |
'verbose_name': 'OAUTH2 client', |
|
26 |
'verbose_name_plural': 'OAUTH2 clients', |
|
27 |
}, |
|
28 |
), |
|
29 |
migrations.AlterModelOptions( |
|
30 |
name='oauth2tempfile', |
|
31 |
options={ |
|
32 |
'ordering': ('creation_date',), |
|
33 |
'verbose_name': 'OAUTH2 temporary file', |
|
34 |
'verbose_name_plural': 'OAUTH2 temporary files', |
|
35 |
}, |
|
36 |
), |
|
37 |
] |
fargo/oauth2/models.py | ||
---|---|---|
1 |
# fargo - document box |
|
2 |
# Copyright (C) 2016-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import datetime |
|
18 |
import uuid |
|
19 | ||
20 |
from django.conf import settings |
|
21 |
from django.core.exceptions import ValidationError |
|
22 |
from django.core.validators import URLValidator |
|
23 |
from django.db import models |
|
24 |
from django.db.models.query import QuerySet |
|
25 |
from django.utils.encoding import python_2_unicode_compatible |
|
26 |
from django.utils.timezone import now |
|
27 |
from django.utils.translation import ugettext_lazy as _ |
|
28 | ||
29 |
from fargo.fargo.models import Document, UserDocument |
|
30 | ||
31 | ||
32 |
def generate_uuid(): |
|
33 |
return uuid.uuid4().hex |
|
34 | ||
35 | ||
36 |
def validate_https_url(data): |
|
37 |
errors = [] |
|
38 |
data = data.strip() |
|
39 |
if not data: |
|
40 |
return |
|
41 |
for url in data.split(): |
|
42 |
try: |
|
43 |
URLValidator(schemes=['http', 'https'])(url) |
|
44 |
except ValidationError as e: |
|
45 |
errors.append(e) |
|
46 |
if errors: |
|
47 |
raise ValidationError(errors) |
|
48 | ||
49 | ||
50 |
@python_2_unicode_compatible |
|
51 |
class OAuth2Client(models.Model): |
|
52 |
client_name = models.CharField(max_length=255) |
|
53 |
redirect_uris = models.TextField(verbose_name=_('redirect URIs'), validators=[validate_https_url]) |
|
54 |
client_id = models.CharField(max_length=255, default=generate_uuid) |
|
55 |
client_secret = models.CharField(max_length=255, default=generate_uuid) |
|
56 | ||
57 |
def __repr__(self): |
|
58 |
return 'OAuth2Client name: %s with id: %s' % (self.client_name, self.client_id) |
|
59 | ||
60 |
def get_redirect_uris(self): |
|
61 |
return self.redirect_uris.split() |
|
62 | ||
63 |
def check_redirect_uri(self, redirect_uri): |
|
64 |
return redirect_uri in self.redirect_uris.strip().split() |
|
65 | ||
66 |
def __str__(self): |
|
67 |
return self.client_name |
|
68 | ||
69 |
class Meta: |
|
70 |
ordering = ('client_name',) |
|
71 |
verbose_name = _('OAUTH2 client') |
|
72 |
verbose_name_plural = _('OAUTH2 clients') |
|
73 | ||
74 | ||
75 |
class CleanupQuerySet(QuerySet): |
|
76 |
def cleanup(self, n=None): |
|
77 |
n = n or now() |
|
78 |
threshold = n - datetime.timedelta(seconds=2 * self.model.get_lifetime()) |
|
79 |
self.filter(creation_date__lt=threshold).delete() |
|
80 | ||
81 | ||
82 |
class OAuth2Authorize(models.Model): |
|
83 |
client = models.ForeignKey(OAuth2Client, on_delete=models.CASCADE) |
|
84 |
user_document = models.ForeignKey(UserDocument, on_delete=models.CASCADE) |
|
85 |
access_token = models.CharField(max_length=255, default=generate_uuid) |
|
86 |
code = models.CharField(max_length=255, default=generate_uuid) |
|
87 |
creation_date = models.DateTimeField(auto_now_add=True) |
|
88 | ||
89 |
objects = CleanupQuerySet.as_manager() |
|
90 | ||
91 |
class Meta: |
|
92 |
ordering = ('creation_date',) |
|
93 |
verbose_name = _('OAUTH2 authorization') |
|
94 |
verbose_name_plural = _('OAUTH2 authorizations') |
|
95 | ||
96 |
@classmethod |
|
97 |
def get_lifetime(cls): |
|
98 |
return max(settings.FARGO_CODE_LIFETIME, settings.FARGO_ACCESS_TOKEN_LIFETIME) |
|
99 | ||
100 |
def __repr__(self): |
|
101 |
return 'OAuth2Authorize for document %r' % self.user_document |
|
102 | ||
103 | ||
104 |
class OAuth2TempFile(models.Model): |
|
105 |
uuid = models.CharField(max_length=32, default=generate_uuid, primary_key=True) |
|
106 |
client = models.ForeignKey(OAuth2Client, on_delete=models.CASCADE) |
|
107 |
document = models.ForeignKey(Document, related_name='oauth2_tempfiles', on_delete=models.CASCADE) |
|
108 |
filename = models.CharField(max_length=512) |
|
109 |
creation_date = models.DateTimeField(auto_now_add=True) |
|
110 | ||
111 |
objects = CleanupQuerySet.as_manager() |
|
112 | ||
113 |
@classmethod |
|
114 |
def get_lifetime(cls): |
|
115 |
return settings.FARGO_OAUTH2_TEMPFILE_LIFETIME |
|
116 | ||
117 |
class Meta: |
|
118 |
ordering = ('creation_date',) |
|
119 |
verbose_name = _('OAUTH2 temporary file') |
|
120 |
verbose_name_plural = _('OAUTH2 temporary files') |
fargo/oauth2/urls.py | ||
---|---|---|
1 |
# fargo - document box |
|
2 |
# Copyright (C) 2016-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.conf.urls import url |
|
18 | ||
19 |
from .views import ( |
|
20 |
authorize_get_document, |
|
21 |
authorize_put_document, |
|
22 |
download_put_document, |
|
23 |
get_document, |
|
24 |
get_document_token, |
|
25 |
put_document, |
|
26 |
) |
|
27 | ||
28 |
urlpatterns = [ |
|
29 |
url(r'get-document/authorize', authorize_get_document, name='oauth2-authorize'), |
|
30 |
url(r'get-document/token', get_document_token, name='oauth2-get-token'), |
|
31 |
url(r'get-document/', get_document, name='oauth2-get-document'), |
|
32 |
url(r'put-document/$', put_document, name='oauth2-put-document'), |
|
33 |
url(r'put-document/(?P<pk>\w+)/authorize/', authorize_put_document, name='oauth2-put-document-authorize'), |
|
34 |
url(r'put-document/(?P<pk>\w+)/download/', download_put_document, name='oauth2-put-document-download'), |
|
35 |
] |
fargo/oauth2/utils.py | ||
---|---|---|
1 |
# fargo - document box |
|
2 |
# Copyright (C) 2016-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import cgi |
|
18 | ||
19 |
from django.conf import settings |
|
20 |
from django.utils import six |
|
21 |
from django.utils.http import unquote |
|
22 |
from django.utils.timezone import now |
|
23 | ||
24 |
from .models import OAuth2Authorize |
|
25 | ||
26 | ||
27 |
def authenticate_bearer(request): |
|
28 |
authorization = request.META.get('HTTP_AUTHORIZATION') |
|
29 |
if not authorization: |
|
30 |
return False |
|
31 |
splitted = authorization.split() |
|
32 |
if len(splitted) < 2: |
|
33 |
return False |
|
34 |
if splitted[0] != 'Bearer': |
|
35 |
return False |
|
36 |
token = splitted[1] |
|
37 |
try: |
|
38 |
authorize = OAuth2Authorize.objects.get(access_token=token) |
|
39 |
if (now() - authorize.creation_date).total_seconds() > settings.FARGO_ACCESS_TOKEN_LIFETIME: |
|
40 |
return False |
|
41 |
return authorize |
|
42 |
except OAuth2Authorize.DoesNotExist: |
|
43 |
return False |
|
44 | ||
45 | ||
46 |
def get_content_disposition_value(request): |
|
47 |
if 'HTTP_CONTENT_DISPOSITION' not in request.META: |
|
48 |
return None, 'missing content-disposition header' |
|
49 |
content_header = request.META['HTTP_CONTENT_DISPOSITION'] |
|
50 |
disposition_type, filename = cgi.parse_header(content_header) |
|
51 |
if disposition_type != 'attachment': |
|
52 |
return None, 'wrong disposition type: attachment expected' |
|
53 |
if 'filename*' in filename: |
|
54 |
encode, country, name = filename['filename*'].split("'") |
|
55 |
return (unquote(name, encode), None) |
|
56 |
elif 'filename' in filename: |
|
57 |
return filename['filename'], None |
|
58 |
else: |
|
59 |
# no filename in header |
|
60 |
return None, 'missing filename(*) parameter in header' |
fargo/oauth2/views.py | ||
---|---|---|
1 |
# fargo - document box |
|
2 |
# Copyright (C) 2016-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import logging |
|
18 | ||
19 |
from django.conf import settings |
|
20 |
from django.contrib.auth.decorators import login_required |
|
21 |
from django.core.files.base import ContentFile |
|
22 |
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect |
|
23 |
from django.shortcuts import get_object_or_404 |
|
24 |
from django.urls import reverse |
|
25 |
from django.utils.http import quote |
|
26 |
from django.utils.timezone import now |
|
27 |
from django.utils.translation import ugettext as _ |
|
28 |
from django.views.decorators.csrf import csrf_exempt |
|
29 |
from django.views.generic import FormView, TemplateView, View |
|
30 |
from rest_framework.response import Response |
|
31 |
from rest_framework.views import APIView |
|
32 | ||
33 |
from fargo.fargo.models import Document, UserDocument |
|
34 |
from fargo.utils import make_url |
|
35 | ||
36 |
from .authentication import FargoOAUTH2Authentication |
|
37 |
from .forms import OAuth2AuthorizeForm |
|
38 |
from .models import OAuth2Authorize, OAuth2Client, OAuth2TempFile |
|
39 |
from .utils import authenticate_bearer, get_content_disposition_value |
|
40 | ||
41 |
logger = logging.getLogger(__name__) |
|
42 | ||
43 | ||
44 |
class OAuth2Exception(Exception): |
|
45 |
pass |
|
46 | ||
47 | ||
48 |
class OAUTH2APIViewMixin(APIView): |
|
49 |
http_method_names = ['post'] |
|
50 |
authentication_classes = (FargoOAUTH2Authentication,) |
|
51 | ||
52 |
@csrf_exempt |
|
53 |
def dispatch(self, request, *args, **kwargs): |
|
54 |
return super().dispatch(request, *args, **kwargs) |
|
55 | ||
56 | ||
57 |
class OAuth2AuthorizeView(FormView): |
|
58 |
template_name = 'fargo/oauth2/authorize.html' |
|
59 |
form_class = OAuth2AuthorizeForm |
|
60 |
success_url = '/' |
|
61 | ||
62 |
def redirect(self, **kwargs): |
|
63 |
'''Return to requester''' |
|
64 |
return HttpResponseRedirect(make_url(self.redirect_uri, **kwargs)) |
|
65 | ||
66 |
def dispatch(self, request): |
|
67 |
self.redirect_uri = request.GET.get('redirect_uri') |
|
68 |
if not self.redirect_uri: |
|
69 |
return HttpResponseBadRequest('missing redirect_uri parameter') |
|
70 | ||
71 |
client_id = request.GET.get('client_id') |
|
72 |
response_type = request.GET.get('response_type') |
|
73 |
if not client_id or not response_type: |
|
74 |
return self.redirect(error='invalid_request') |
|
75 |
if response_type != 'code': |
|
76 |
return self.redirect(error='unsupported_response_type') |
|
77 |
try: |
|
78 |
self.client = OAuth2Client.objects.get(client_id=client_id) |
|
79 |
if not self.client.check_redirect_uri(self.redirect_uri): |
|
80 |
return self.redirect(error='invalid_redirect_uri') |
|
81 |
except OAuth2Client.DoesNotExist: |
|
82 |
return self.redirect(error='unauthorized_client') |
|
83 |
self.state = request.GET.get('state', None) |
|
84 |
return super().dispatch(request) |
|
85 | ||
86 |
def post(self, request): |
|
87 |
if 'cancel' in request.POST: |
|
88 |
return self.redirect(error='access_denied') |
|
89 |
return super().post(request) |
|
90 | ||
91 |
def get_form_kwargs(self): |
|
92 |
kwargs = super().get_form_kwargs() |
|
93 |
kwargs['user'] = self.request.user |
|
94 |
return kwargs |
|
95 | ||
96 |
def form_valid(self, form): |
|
97 |
document = form.cleaned_data['document'] |
|
98 |
authorization = OAuth2Authorize.objects.create(client=self.client, user_document=document) |
|
99 |
logger.info( |
|
100 |
'user %s authorized client "%s" to get document "%s" (%s) with code "%s"', |
|
101 |
self.request.user, |
|
102 |
self.client, |
|
103 |
document, |
|
104 |
document.pk, |
|
105 |
authorization.code, |
|
106 |
) |
|
107 |
return self.redirect(code=authorization.code, state=self.state) |
|
108 | ||
109 |
def get_context_data(self, **kwargs): |
|
110 |
kwargs['oauth2_client'] = self.client |
|
111 |
return super().get_context_data(**kwargs) |
|
112 | ||
113 | ||
114 |
authorize_get_document = login_required(OAuth2AuthorizeView.as_view()) |
|
115 | ||
116 | ||
117 |
class GetDocumentTokenView(OAUTH2APIViewMixin): |
|
118 |
def error(self, error, description=None): |
|
119 |
data = { |
|
120 |
'error': error, |
|
121 |
} |
|
122 |
if description: |
|
123 |
data['error_description'] = description |
|
124 |
return Response(data, status=400) |
|
125 | ||
126 |
def post(self, request): |
|
127 |
if request.data['grant_type'] != 'authorization_code': |
|
128 |
return self.error('unsupported_grant_type') |
|
129 | ||
130 |
try: |
|
131 |
authorize = OAuth2Authorize.objects.get(code=request.data['code']) |
|
132 |
except OAuth2Authorize.DoesNotExist: |
|
133 |
return self.error('invalid_grant', 'code is unknown') |
|
134 | ||
135 |
if (now() - authorize.creation_date).total_seconds() > settings.FARGO_CODE_LIFETIME: |
|
136 |
return self.error('invalid_grant', 'code is expired') |
|
137 |
logger.info( |
|
138 |
'client "%s" resolved code "%s" to access token "%s"', |
|
139 |
request.user.oauth2_client, |
|
140 |
authorize.code, |
|
141 |
authorize.access_token, |
|
142 |
) |
|
143 |
return Response( |
|
144 |
{'access_token': authorize.access_token, 'expires': settings.FARGO_ACCESS_TOKEN_LIFETIME} |
|
145 |
) |
|
146 | ||
147 | ||
148 |
get_document_token = GetDocumentTokenView.as_view() |
|
149 | ||
150 | ||
151 |
def document_response(user_document): |
|
152 |
response = HttpResponse( |
|
153 |
content=user_document.document.content.chunks(), status=200, content_type='application/octet-stream' |
|
154 |
) |
|
155 | ||
156 |
filename = user_document.filename |
|
157 |
ascii_filename = filename.encode('ascii', 'replace').decode() |
|
158 |
percent_encoded_filename = quote(filename.encode('utf8'), safe='') |
|
159 |
response['Content-Disposition'] = 'attachment; filename="%s"; filename*=UTF-8\'\'%s' % ( |
|
160 |
ascii_filename, |
|
161 |
percent_encoded_filename, |
|
162 |
) |
|
163 |
return response |
|
164 | ||
165 | ||
166 |
def get_document(request): |
|
167 |
oauth_authorize = authenticate_bearer(request) |
|
168 |
if not oauth_authorize: |
|
169 |
return HttpResponseBadRequest('http bearer authentication failed: invalid authorization header') |
|
170 | ||
171 |
user_document = oauth_authorize.user_document |
|
172 |
logger.info( |
|
173 |
'client "%s" retrieved document "%s" (%s) with access token "%s"', |
|
174 |
oauth_authorize.client, |
|
175 |
user_document, |
|
176 |
user_document.pk, |
|
177 |
oauth_authorize.access_token, |
|
178 |
) |
|
179 |
return document_response(user_document) |
|
180 | ||
181 | ||
182 |
class PutDocumentAPIView(OAUTH2APIViewMixin): |
|
183 |
def post(self, request, *args, **kwargs): |
|
184 |
filename, error = get_content_disposition_value(request) |
|
185 |
if error: |
|
186 |
return HttpResponseBadRequest(error) |
|
187 | ||
188 |
f = ContentFile(request.body, name=filename) |
|
189 |
document = Document.objects.get_by_file(f) |
|
190 |
oauth2_document = OAuth2TempFile.objects.create( |
|
191 |
client=request.user.oauth2_client, document=document, filename=filename |
|
192 |
) |
|
193 |
uri = reverse('oauth2-put-document-authorize', args=[oauth2_document.pk]) |
|
194 | ||
195 |
response = Response() |
|
196 |
response['Location'] = uri |
|
197 |
logger.info( |
|
198 |
'client "%s" uploaded document "%s" (%s)', |
|
199 |
request.user.oauth2_client, |
|
200 |
filename, |
|
201 |
oauth2_document.pk, |
|
202 |
) |
|
203 |
return response |
|
204 | ||
205 | ||
206 |
put_document = PutDocumentAPIView.as_view() |
|
207 | ||
208 | ||
209 |
class OAuth2AuthorizePutView(TemplateView): |
|
210 |
template_name = 'fargo/oauth2/confirm.html' |
|
211 | ||
212 |
def redirect(self, **kwargs): |
|
213 |
'''Return to requester''' |
|
214 |
return HttpResponseRedirect(make_url(self.redirect_uri, **kwargs)) |
|
215 | ||
216 |
def dispatch(self, request, *args, **kwargs): |
|
217 |
self.redirect_uri = request.GET.get('redirect_uri', '') |
|
218 |
if not self.redirect_uri: |
|
219 |
return HttpResponseBadRequest('missing redirect_uri parameter') |
|
220 |
self.oauth2_document = OAuth2TempFile.objects.filter(pk=kwargs['pk']).first() |
|
221 |
return super().dispatch(request) |
|
222 | ||
223 |
def get_context_data(self, **kwargs): |
|
224 |
if self.oauth2_document: |
|
225 |
kwargs['oauth2_document'] = self.oauth2_document |
|
226 |
kwargs['filename'] = self.oauth2_document.filename |
|
227 |
kwargs['thumbnail_image'] = self.oauth2_document.document.thumbnail_image |
|
228 |
kwargs['oauth2_client'] = self.oauth2_document.client |
|
229 |
kwargs['download_url'] = reverse( |
|
230 |
'oauth2-put-document-download', kwargs={'pk': self.oauth2_document.pk} |
|
231 |
) |
|
232 |
# verify if document already exists |
|
233 |
if not UserDocument.objects.filter( |
|
234 |
user=self.request.user, document=self.oauth2_document.document |
|
235 |
).exists(): |
|
236 |
kwargs['error_message'] = '' |
|
237 |
else: |
|
238 |
kwargs['error_message'] = _('This document is already in your portfolio') |
|
239 |
kwargs['redirect_uri'] = self.request.GET['redirect_uri'] |
|
240 |
else: |
|
241 |
kwargs['error_message'] = _('The document has not been uploaded') |
|
242 |
kwargs['redirect_uri'] = self.request.GET['redirect_uri'] |
|
243 |
return super().get_context_data(**kwargs) |
|
244 | ||
245 |
def post(self, request): |
|
246 |
if not self.oauth2_document: |
|
247 |
return self.get(request) |
|
248 | ||
249 |
try: |
|
250 |
if 'cancel' in request.POST: |
|
251 |
return self.redirect(error='access_denied') |
|
252 | ||
253 |
UserDocument.objects.create( |
|
254 |
user=request.user, |
|
255 |
document=self.oauth2_document.document, |
|
256 |
filename=self.oauth2_document.filename, |
|
257 |
) |
|
258 |
logger.info( |
|
259 |
'user %s accepted document "%s" (%s) from client "%s"', |
|
260 |
request.user, |
|
261 |
self.oauth2_document.filename, |
|
262 |
self.oauth2_document.pk, |
|
263 |
self.oauth2_document.client, |
|
264 |
) |
|
265 |
return self.redirect() |
|
266 |
finally: |
|
267 |
self.oauth2_document.delete() |
|
268 | ||
269 | ||
270 |
authorize_put_document = login_required(OAuth2AuthorizePutView.as_view()) |
|
271 | ||
272 | ||
273 |
class DownloadPutDocument(View): |
|
274 |
def get(self, request, *args, **kwargs): |
|
275 |
oauth2_document = get_object_or_404(OAuth2TempFile, pk=kwargs['pk']) |
|
276 |
return document_response(oauth2_document) |
|
277 | ||
278 | ||
279 |
download_put_document = login_required(DownloadPutDocument.as_view()) |
fargo/settings.py | ||
---|---|---|
55 | 55 |
'django_tables2', |
56 | 56 |
'gadjo', |
57 | 57 |
'fargo.fargo', |
58 |
'rest_framework', |
|
59 |
'fargo.oauth2', |
|
60 | 58 |
'sorl.thumbnail', |
61 | 59 |
) |
62 | 60 | |
... | ... | |
258 | 256 | |
259 | 257 |
FARGO_CODE_LIFETIME = 300 |
260 | 258 |
FARGO_ACCESS_TOKEN_LIFETIME = 3600 |
261 |
FARGO_OAUTH2_TEMPFILE_LIFETIME = 86400 |
|
262 | 259 | |
263 | 260 |
local_settings_file = os.environ.get( |
264 | 261 |
'FARGO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py') |
fargo/templates/fargo/oauth2/authorize.html | ||
---|---|---|
1 |
{% extends "fargo/base.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block content %} |
|
5 |
<div id="fargo-oauth2-authorize"> |
|
6 |
{% block form-intro %} |
|
7 |
{% blocktrans %} |
|
8 |
<p>The service {{ oauth2_client }} want to get one of your documents.</p> |
|
9 |
{% endblocktrans %} |
|
10 |
{% endblock %} |
|
11 |
{% block form %} |
|
12 |
<form method="post" enctype="multipart/form-data"> |
|
13 |
{% csrf_token %} |
|
14 |
{{ form.as_p }} |
|
15 |
<div class="buttons"> |
|
16 |
<button name="submit">{% trans "Choose" %}</button> |
|
17 |
<button name="cancel">{% trans "Cancel" %}</button> |
|
18 |
</div> |
|
19 |
</form> |
|
20 |
{% endblock %} |
|
21 |
</div> |
|
22 |
{% endblock %} |
fargo/templates/fargo/oauth2/confirm.html | ||
---|---|---|
1 |
{% extends "fargo/base.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block content %} |
|
5 |
<div id="fargo-oauth2-confirm"> |
|
6 |
{% if oauth2_document %} |
|
7 |
{% block form-intro %} |
|
8 |
<p> |
|
9 |
{% blocktrans %} |
|
10 |
The service {{ oauth2_client }} want to add the document "<a href="{{ download_url }}"><em class="filename">{{ filename }}</em></a>" to your portfolio. |
|
11 |
{% endblocktrans %} |
|
12 |
</p> |
|
13 |
{% if thumbnail %}<p class="fargo-thumbnail"><img src="{{ thumbnail.src }}" height="{{ thumbnail.height }}" width="{{ thumbnail.width }}"/></p>{% endif %} |
|
14 |
{% endblock %} |
|
15 |
{% endif %} |
|
16 |
{% if error_message %} |
|
17 |
{% block error-message %} |
|
18 |
<p>{% trans error_message %}</p> |
|
19 |
{% endblock %} |
|
20 |
{% endif %} |
|
21 |
{% block form %} |
|
22 |
<form id="send-file" method="post"> |
|
23 |
{% csrf_token %} |
|
24 |
<div class="buttons"> |
|
25 |
{% if not error_message %} |
|
26 |
<button name="submit">{% trans "Allow" %}</button> |
|
27 |
{% endif %} |
|
28 |
<button name="cancel">{% trans "Cancel" %}</button> |
|
29 |
</div> |
|
30 |
</form> |
|
31 |
{% endblock %} |
|
32 |
</div> |
|
33 |
{% endblock %} |
fargo/urls.py | ||
---|---|---|
55 | 55 |
url(r'^api/documents/push/$', push_document, name='fargo-api-push-document'), |
56 | 56 |
url(r'^api/documents/recently-added/$', recent_documents), |
57 | 57 |
url(r'^api/', include(router.urls)), |
58 |
url(r'^api/', include('fargo.oauth2.urls')), |
|
59 | 58 |
] |
60 | 59 | |
61 | 60 |
if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS: |
tests/conftest.py | ||
---|---|---|
100 | 100 | |
101 | 101 |
@pytest.fixture |
102 | 102 |
def document(): |
103 |
with open('tests/test_oauth2.txt', 'rb') as f:
|
|
104 |
content = ContentFile(f.read(), 'test_oauth2.txt')
|
|
103 |
with open('tests/test_file.txt', 'rb') as f:
|
|
104 |
content = ContentFile(f.read(), 'test_file.txt')
|
|
105 | 105 | |
106 | 106 |
return Document.objects.get_by_file(content) |
107 | 107 |
tests/test_commands.py | ||
---|---|---|
21 | 21 |
from django.core.management import call_command |
22 | 22 | |
23 | 23 |
from fargo.fargo.models import Document, UserDocument |
24 |
from fargo.oauth2.models import OAuth2Client, OAuth2TempFile |
|
25 | 24 | |
26 | 25 | |
27 | 26 |
def test_cleanup(freezer, john_doe): |
28 | 27 |
start = freezer() |
29 | 28 | |
30 |
client = OAuth2Client.objects.create(client_name='c', redirect_uris='') |
|
31 | ||
32 | 29 |
foo = Document.objects.create(content=ContentFile(b'foo', name='foo.txt')) |
33 |
bar = Document.objects.create(content=ContentFile(b'bar', name='bar.txt')) |
|
34 | 30 |
UserDocument.objects.create(user=john_doe, document=foo, filename='foo.txt', title='', description='') |
35 |
OAuth2TempFile.objects.create(document=bar, client=client, filename='bar.txt') |
|
36 | 31 | |
37 | 32 |
call_command('fargo-cleanup') |
38 | 33 | |
39 | 34 |
assert UserDocument.objects.all().count() |
40 |
assert OAuth2TempFile.objects.all().count() |
|
41 |
assert Document.objects.all().count() == 2 |
|
35 |
assert Document.objects.all().count() == 1 |
|
42 | 36 | |
43 | 37 |
User.objects.all().delete() |
44 | 38 | |
45 | 39 |
assert not UserDocument.objects.all().count() |
46 |
assert Document.objects.all().count() == 2 |
|
47 | ||
48 |
call_command('fargo-cleanup') |
|
49 | ||
50 |
assert Document.objects.all().count() == 2 |
|
40 |
assert Document.objects.all().count() == 1 |
|
51 | 41 | |
52 |
freezer.move_to(start + datetime.timedelta(seconds=120)) |
|
53 | 42 |
call_command('fargo-cleanup') |
54 | 43 | |
55 | 44 |
assert Document.objects.all().count() == 1 |
... | ... | |
58 | 47 | |
59 | 48 |
call_command('fargo-cleanup') |
60 | 49 | |
61 |
assert not OAuth2TempFile.objects.count() |
|
62 |
assert Document.objects.count() |
|
63 | ||
64 |
call_command('fargo-cleanup') |
|
65 | ||
66 | 50 |
assert not Document.objects.count() |
tests/test_file.txt | ||
---|---|---|
1 |
Lorem ipsum dolor sit amet, atqui animal constituto sit no, pri liber mandamus |
|
2 |
ea, usu no duis etiam copiosae. Ius liber scripserit at, nam nisl nonumes ne. |
|
3 |
Ut vidit clita possim eum, eos eu melius perfecto. Ne ius intellegam |
|
4 |
reformidans, pri repudiare conceptam definitiones cu, duo tota bonorum no. |
|
5 |
Lorem omnesque principes in ius, facilis erroribus cu usu. Eum liber homero |
|
6 |
qualisque id, cu pri illum consetetur. |
tests/test_oauth2.py | ||
---|---|---|
1 |
# fargo - document box |
|
2 |
# Copyright (C) 2016-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import json |
|
18 |
import os |
|
19 |
from unittest import mock |
|
20 | ||
21 |
import pytest |
|
22 |
from django.core.management import call_command |
|
23 |
from django.urls import reverse |
|
24 |
from django.utils.http import quote, urlencode |
|
25 |
from django.utils.six.moves.urllib import parse as urlparse |
|
26 |
from test_manager import login |
|
27 | ||
28 |
from fargo.fargo.models import UserDocument |
|
29 |
from fargo.oauth2.models import OAuth2Authorize, OAuth2Client, OAuth2TempFile |
|
30 | ||
31 |
pytestmark = pytest.mark.django_db |
|
32 | ||
33 | ||
34 |
class FakedResponse(mock.Mock): |
|
35 |
def json(self): |
|
36 |
return json.loads(self.content) |
|
37 | ||
38 | ||
39 |
@pytest.fixture |
|
40 |
def oauth2_client(): |
|
41 |
return OAuth2Client.objects.create( |
|
42 |
client_name='test_oauth2', |
|
43 |
client_id='client-id', |
|
44 |
client_secret='client-secret', |
|
45 |
redirect_uris='https://example.net/document https://doc.example.net/ https://example.com', |
|
46 |
) |
|
47 | ||
48 | ||
49 |
def assert_error_redirect(url, error): |
|
50 |
assert urlparse.urlparse(url).query == 'error=%s' % error |
|
51 | ||
52 | ||
53 |
def test_get_document_oauth2(app, john_doe, oauth2_client, user_doc): |
|
54 |
login(app, user=john_doe) |
|
55 |
url = reverse('oauth2-authorize') |
|
56 |
params = {'client_secret': oauth2_client.client_secret, 'response_type': 'code', 'state': 'achipeachope'} |
|
57 |
# test missing redirect_uri |
|
58 |
resp = app.get(url, params={}, status=400) |
|
59 |
assert resp.text == 'missing redirect_uri parameter' |
|
60 |
# test missing client id |
|
61 |
params['redirect_uri'] = 'https://toto.example.com' |
|
62 |
resp = app.get(url, params=params, status=302) |
|
63 |
assert_error_redirect(resp.url, 'invalid_request') |
|
64 |
# test invalid response type |
|
65 |
params['client_id'] = oauth2_client.client_id |
|
66 |
params['response_type'] = 'token' |
|
67 |
resp = app.get(url, params=params, status=302) |
|
68 |
assert_error_redirect(resp.url, 'unsupported_response_type') |
|
69 |
# test invalid redirect uri |
|
70 |
params['response_type'] = 'code' |
|
71 |
resp = app.get(url, params=params, status=302) |
|
72 |
assert_error_redirect(resp.url, 'invalid_redirect_uri') |
|
73 | ||
74 |
params['redirect_uri'] = 'https://example.com' |
|
75 |
resp = app.get(url, params=params) |
|
76 | ||
77 |
assert resp.status_code == 200 |
|
78 |
assert len(resp.form['document'].options) == 2 |
|
79 |
options = resp.form['document'].options |
|
80 |
assert 'éléphant.txt' in options[1] |
|
81 | ||
82 |
# select the second document 'éléphant.txt' |
|
83 |
resp.form['document'].select(options[1][0]) |
|
84 |
resp = resp.form.submit() |
|
85 |
# check that the authorization has been registered for the user document |
|
86 |
assert len(OAuth2Authorize.objects.filter(user_document__user=john_doe)) == 1 |
|
87 |
auth = OAuth2Authorize.objects.filter(user_document__user=john_doe)[0] |
|
88 |
assert resp.status_code == 302 |
|
89 |
query = urlparse.urlparse(resp.location).query |
|
90 |
assert [auth.code] == urlparse.parse_qs(query)['code'] |
|
91 |
assert ['achipeachope'] == urlparse.parse_qs(query)['state'] |
|
92 | ||
93 |
params.pop('response_type') |
|
94 |
params.pop('state') |
|
95 |
params['grant_type'] = 'authorization_code' |
|
96 |
params['code'] = auth.code |
|
97 | ||
98 |
url = reverse('oauth2-get-token') |
|
99 |
app.authorization = ('Basic', (oauth2_client.client_id, oauth2_client.client_secret)) |
|
100 |
resp = app.post(url, params=params, status=200) |
|
101 |
assert 'access_token' in resp.json |
|
102 |
assert 'expires' in resp.json |
|
103 |
assert resp.json['access_token'] == auth.access_token |
|
104 | ||
105 |
url = reverse('oauth2-get-document') |
|
106 |
app.authorization = ('Bearer', str(auth.access_token)) |
|
107 |
resp = app.get(url, status=200) |
|
108 | ||
109 |
assert resp.content_type == 'application/octet-stream' |
|
110 |
assert 'Content-disposition' in resp.headers |
|
111 |
content_disposition = resp.content_disposition.replace(' ', '').split(';') |
|
112 |
assert content_disposition[0] == 'attachment' |
|
113 |
assert content_disposition[1] == 'filename="?l?phant.txt"' |
|
114 |
assert content_disposition[2] == 'filename*=UTF-8\'\'%C3%A9l%C3%A9phant.txt' |
|
115 | ||
116 | ||
117 |
def test_put_document(app, john_doe, oauth2_client): |
|
118 |
login(app, user=john_doe) |
|
119 |
with open('tests/test_oauth2.txt', 'rb') as f: |
|
120 |
data = f.read() |
|
121 | ||
122 |
url = reverse('oauth2-put-document') |
|
123 |
resp = app.post(url, params=data, status=401) |
|
124 | ||
125 |
app.authorization = ('Basic', (str(oauth2_client.client_id), str(oauth2_client.client_secret))) |
|
126 |
resp = app.post(url, params=data, status=400) |
|
127 |
assert 'missing content-disposition header' in resp.text |
|
128 | ||
129 |
filename = 'éléphant.txt' |
|
130 |
percent_encode_filename = quote(filename, safe='') |
|
131 |
headers = { |
|
132 |
'Content-disposition': 'attachment; filename="%s"; filename*=UTF-8\'\'%s' |
|
133 |
% (filename, percent_encode_filename) |
|
134 |
} |
|
135 | ||
136 |
assert len(OAuth2TempFile.objects.all()) == 0 |
|
137 | ||
138 |
resp = app.post(url, params=data, headers=headers, status=200) |
|
139 |
# test that we can still push the same document |
|
140 |
resp = app.post(url, params=data, headers=headers, status=200) |
|
141 | ||
142 |
assert len(OAuth2TempFile.objects.all()) == 2 |
|
143 | ||
144 |
doc = OAuth2TempFile.objects.latest('creation_date') |
|
145 |
location = reverse('oauth2-put-document-authorize', kwargs={'pk': doc.pk}) |
|
146 |
assert location in resp.location |
|
147 | ||
148 |
app.authorization = None |
|
149 |
url = location + '?%s' % urlencode({'redirect_uri': 'https://example.com'}) |
|
150 |
resp = app.get(url, status=200) |
|
151 | ||
152 |
assert OAuth2TempFile.objects.count() == 2 |
|
153 |
assert UserDocument.objects.count() == 0 |
|
154 | ||
155 |
resp = resp.form.submit() |
|
156 |
assert resp.status_code == 302 |
|
157 |
assert resp.location == 'https://example.com' |
|
158 | ||
159 |
assert OAuth2TempFile.objects.count() == 1 |
|
160 |
assert UserDocument.objects.count() == 1 |
|
161 |
assert OAuth2TempFile.objects.get().document == UserDocument.objects.get().document |
|
162 |
assert UserDocument.objects.filter(user=john_doe, document=doc.document, filename='éléphant.txt').exists() |
|
163 | ||
164 | ||
165 |
def test_confirm_put_document_file_exception(app, oauth2_client, john_doe, user_doc): |
|
166 |
login(app, user=john_doe) |
|
167 |
oauth_tmp_file = OAuth2TempFile.objects.create( |
|
168 |
client=oauth2_client, document=user_doc.document, filename=user_doc.filename |
|
169 |
) |
|
170 | ||
171 |
url = reverse('oauth2-put-document-authorize', kwargs={'pk': 'fakemofo'}) |
|
172 |
url += '?%s' % urlencode({'redirect_uri': 'https://example.com'}) |
|
173 |
resp = app.get(url) |
|
174 |
assert 'The document has not been uploaded' in resp.text |
|
175 | ||
176 |
url = reverse('oauth2-put-document-authorize', kwargs={'pk': oauth_tmp_file.pk}) |
|
177 |
url += '?%s' % urlencode({'redirect_uri': 'https://example.com'}) |
|
178 |
resp = app.get(url) |
|
179 |
assert 'This document is already in your portfolio' in resp.text |
|
180 | ||
181 | ||
182 |
@mock.patch('fargo.oauth2.authentication.requests.post') |
|
183 |
def test_idp_authentication(mocked_post, settings, app, oauth2_client, john_doe, user_doc): |
|
184 |
login(app, user=john_doe) |
|
185 |
url = reverse('oauth2-authorize') |
|
186 |
params = { |
|
187 |
'client_id': oauth2_client.client_id, |
|
188 |
'client_secret': 'fake', |
|
189 |
'response_type': 'code', |
|
190 |
'state': 'achipeachope', |
|
191 |
'redirect': 'https://example.com/', |
|
192 |
} |
|
193 |
params['redirect_uri'] = 'https://example.com' |
|
194 |
resp = app.get(url, params=params) |
|
195 |
options = resp.form['document'].options |
|
196 |
assert 'éléphant.txt' in options[1] |
|
197 |
resp.form['document'].select(options[1][0]) |
|
198 |
resp = resp.form.submit() |
|
199 |
auth = OAuth2Authorize.objects.filter(user_document__user=john_doe)[0] |
|
200 |
params.pop('response_type') |
|
201 |
params.pop('state') |
|
202 |
params['grant_type'] = 'authorization_code' |
|
203 |
params['code'] = auth.code |
|
204 | ||
205 |
url = reverse('oauth2-get-token') |
|
206 |
# when remote remote idp not set |
|
207 |
app.authorization = ('Basic', ('client-id', 'fake')) |
|
208 |
resp = app.post(url, params=params, status=401) |
|
209 |
resp.json['detail'] == 'Invalid client_id/client_secret.' |
|
210 |
# when remote idp fails to authenticate rp |
|
211 |
settings.FARGO_IDP_URL = 'https://idp.example.org' |
|
212 |
response = {"result": 0, "errors": ["Invalid username/password."]} |
|
213 |
mocked_post.return_value = FakedResponse(content=json.dumps(response)) |
|
214 |
resp = app.post(url, params=params, status=401) |
|
215 |
resp.json['detail'] == 'Invalid client_id/client_secret.' |
|
216 |
# when remote idp authenticates rp |
|
217 |
response = {"result": 1, "errors": []} |
|
218 |
mocked_post.return_value = FakedResponse(content=json.dumps(response)) |
|
219 |
resp = app.post(url, params=params, status=200) |
|
220 |
assert resp.json['access_token'] == auth.access_token |
|
221 |
url = reverse('oauth2-get-document') |
|
222 |
app.authorization = ('Bearer', str(auth.access_token)) |
|
223 |
resp = app.get(url, status=200) |
|
224 | ||
225 | ||
226 |
def test_command_create_client(db): |
|
227 |
call_command('oauth2-create-client', 'test', 'https://example.com/') |
|
228 |
client = OAuth2Client.objects.get() |
|
229 |
assert client.client_name == 'test' |
|
230 |
assert client.redirect_uris == 'https://example.com/' |
|
231 |
assert client.client_id |
|
232 |
assert client.client_secret |
|
233 | ||
234 |
OAuth2Client.objects.all().delete() |
|
235 | ||
236 |
call_command( |
|
237 |
'oauth2-create-client', 'test', 'https://example.com/', '--client-id=wtf', '--client-secret=whocares' |
|
238 |
) |
|
239 |
client = OAuth2Client.objects.get() |
|
240 |
assert client.client_name == 'test' |
|
241 |
assert client.redirect_uris == 'https://example.com/' |
|
242 |
assert client.client_id == 'wtf' |
|
243 |
assert client.client_secret == 'whocares' |
|
244 | ||
245 | ||
246 |
def test_command_put_document(db, capsys, app, john_doe): |
|
247 |
call_command('oauth2-create-client', 'test', 'https://example.com/') |
|
248 |
client = OAuth2Client.objects.get() |
|
249 |
path = os.path.join(os.path.dirname(__file__), 'pdf-sample.pdf') |
|
250 |
redirect_uri = 'https://example.com/' |
|
251 |
call_command('oauth2-put-document', '--client-id=%s' % client.pk, redirect_uri, path) |
|
252 |
out, err = capsys.readouterr() |
|
253 |
assert err == '' |
|
254 |
url = out.strip() |
|
255 |
response = app.get(url).follow() |
|
256 |
response.form.set('username', john_doe.username) |
|
257 |
response.form.set('password', john_doe.username) |
|
258 |
response = response.form.submit().follow() |
|
259 |
assert 'pdf-sample.pdf' in response |
|
260 |
temp_file = OAuth2TempFile.objects.get() |
|
261 |
assert temp_file.uuid in response |
|
262 |
response = response.form.submit('accept') |
|
263 |
assert response['Location'] == redirect_uri |
|
264 |
assert UserDocument.objects.filter(user=john_doe, document=temp_file.document).exists() |
|
265 |
assert OAuth2TempFile.objects.count() == 0 |
tests/test_oauth2.txt | ||
---|---|---|
1 |
Poème hymne à la beauté du recueil les fleurs du mal de Charles Baudelaire |
|
2 | ||
3 |
Viens-tu du ciel profond ou sors-tu de l'abîme, |
|
4 |
Ô Beauté ! ton regard, infernal et divin, |
|
5 |
Verse confusément le bienfait et le crime, |
|
6 |
Et l'on peut pour cela te comparer au vin. |
|
7 | ||
8 |
Tu contiens dans ton oeil le couchant et l'aurore ; |
|
9 |
Tu répands des parfums comme un soir orageux ; |
|
10 |
Tes baisers sont un philtre et ta bouche une amphore |
|
11 |
Qui font le héros lâche et l'enfant courageux. |
|
12 | ||
13 |
Sors-tu du gouffre noir ou descends-tu des astres ? |
|
14 |
Le Destin charmé suit tes jupons comme un chien ; |
|
15 |
Tu sèmes au hasard la joie et les désastres, |
|
16 |
Et tu gouvernes tout et ne réponds de rien. |
|
17 | ||
18 |
Tu marches sur des morts, Beauté, dont tu te moques ; |
|
19 |
De tes bijoux l'Horreur n'est pas le moins charmant, |
|
20 |
Et le Meurtre, parmi tes plus chères breloques, |
|
21 |
Sur ton ventre orgueilleux danse amoureusement. |
|
22 | ||
23 |
L'éphémère ébloui vole vers toi, chandelle, |
|
24 |
Crépite, flambe et dit : Bénissons ce flambeau ! |
|
25 |
L'amoureux pantelant incliné sur sa belle |
|
26 |
A l'air d'un moribond caressant son tombeau. |
|
27 | ||
28 |
Que tu viennes du ciel ou de l'enfer, qu'importe, |
|
29 |
Ô Beauté ! monstre énorme, effrayant, ingénu ! |
|
30 |
Si ton oeil, ton souris, ton pied, m'ouvrent la porte |
|
31 |
D'un Infini que j'aime et n'ai jamais connu ? |
|
32 | ||
33 |
De Satan ou de Dieu, qu'importe ? Ange ou Sirène, |
|
34 |
Qu'importe, si tu rends, - fée aux yeux de velours, |
|
35 |
Rythme, parfum, lueur, ô mon unique reine ! - |
|
36 |
L'univers moins hideux et les instants moins lourds ? |
|
37 |
- |