diff --git a/accounts/forms.py b/accounts/forms.py index 8c05b75..e3bcd42 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -1,9 +1,10 @@ +from appsettings.settings import app_settings +from django.contrib.auth import get_user_model from django.forms import ModelForm, ValidationError from django.utils.translation import ugettext_lazy as _ -from appsettings.models import AppSettings - -from .models import UserInstance +from .models import UserInstance, UserSSHKey +from .utils import validate_ssh_key class UserInstanceForm(ModelForm): @@ -18,7 +19,7 @@ class UserInstanceForm(ModelForm): def clean_instance(self): instance = self.cleaned_data['instance'] - if AppSettings.objects.get(key="ALLOW_INSTANCE_MULTIPLE_OWNER").value == 'False': + if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == 'False': exists = UserInstance.objects.filter(instance=instance) if exists: raise ValidationError(_('Instance owned by another user')) @@ -28,3 +29,43 @@ class UserInstanceForm(ModelForm): class Meta: model = UserInstance fields = '__all__' + + +class ProfileForm(ModelForm): + class Meta: + model = get_user_model() + fields = ('first_name', 'last_name', 'email') + + +class UserSSHKeyForm(ModelForm): + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) + self.publickeys = UserSSHKey.objects.filter(user=self.user) + super().__init__(*args, **kwargs) + + def clean_keyname(self): + for key in self.publickeys: + if self.cleaned_data['keyname'] == key.keyname: + raise ValidationError(_("Key name already exist")) + + return self.cleaned_data['keyname'] + + def clean_keypublic(self): + for key in self.publickeys: + if self.cleaned_data['keypublic'] == key.keypublic: + raise ValidationError(_("Public key already exist")) + + if not validate_ssh_key(self.cleaned_data['keypublic']): + raise ValidationError(_('Invalid key')) + return self.cleaned_data['keypublic'] + + def save(self, commit=True): + ssh_key = super().save(commit=False) + ssh_key.user = self.user + if commit: + ssh_key.save() + return ssh_key + + class Meta: + model = UserSSHKey + fields = ('keyname', 'keypublic') diff --git a/accounts/models.py b/accounts/models.py index 33b120c..15e0ddf 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,9 +1,7 @@ -from django.conf import settings from django.contrib.auth.models import User from django.core.validators import MinValueValidator from django.db import models from django.utils.translation import ugettext_lazy as _ - from instances.models import Instance @@ -11,6 +9,7 @@ class UserInstanceManager(models.Manager): def get_queryset(self): return super().get_queryset().select_related('instance', 'user') + class UserInstance(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) instance = models.ForeignKey(Instance, on_delete=models.CASCADE) diff --git a/accounts/templates/account.html b/accounts/templates/account.html index 2deacf8..54e3ad9 100644 --- a/accounts/templates/account.html +++ b/accounts/templates/account.html @@ -5,20 +5,15 @@ {% load qr_code %} {% block title %}{% trans "User Profile" %} - {{ user }}{% endblock %} +{% block page_header %}{% trans "User Profile" %}: {{ user }}{% endblock page_header %} + +{% block page_header_extra %} +<a href="{% url 'accounts:user_instance_create' user.id %}" class="btn btn-success"> + {% icon 'plus' %} +</a> +{% endblock page_header_extra %} + {% block content %} - <!-- Page Heading --> - <div class="row"> - <div class="col-lg-12"> - <a href="{% url 'user_instance_create' user.id %}" class="btn btn-success btn-header float-right"> - {% icon 'plus' %} - </a> - <h2 class="page-header">{% trans "User Profile" %}: {{ user }}</h2> - </div> - </div> - <!-- /.row --> - - {% include 'errors_block.html' %} - <ul class="nav nav-tabs"> <li class="nav-item"> <a class="nav-link active" data-toggle="tab" href="#instances">{% trans "Instances" %}</a> @@ -55,12 +50,12 @@ <td>{{ inst.is_change }}</td> <td>{{ inst.is_delete }}</td> <td style="width:5px;"> - <a href="{% url 'user_instance_update' inst.id %}" class="btn btn-sm btn-secondary" title="{% trans "edit" %}"> + <a href="{% url 'accounts:user_instance_update' inst.id %}" class="btn btn-sm btn-secondary" title="{% trans "edit" %}"> {% icon 'pencil' %} </a> </td> <td style="width:5px;"> - <a class="btn btn-sm btn-secondary" href="{% url 'user_instance_delete' inst.id %}" title="{% trans "Delete" %}"> + <a class="btn btn-sm btn-secondary" href="{% url 'accounts:user_instance_delete' inst.id %}" title="{% trans "Delete" %}"> {% icon 'trash' %} </a> </td> diff --git a/accounts/templates/accounts-list.html b/accounts/templates/accounts-list.html index 9cea6bb..a600678 100644 --- a/accounts/templates/accounts-list.html +++ b/accounts/templates/accounts-list.html @@ -41,7 +41,7 @@ {% for user in users %} <tr class="{% if not user.is_active %}danger{% endif %}"> <td> - <a href="{% url 'account' user.id %}"><strong>{{ user.username }}</strong></a> + <a href="{% url 'accounts:account' user.id %}"><strong>{{ user.username }}</strong></a> <a data-toggle="modal" href="#editUser{{ user.id }}" class="float-right" title="{% trans "Edit" %}"> <span class="fa fa-cog"></span> </a> diff --git a/accounts/templates/accounts.html b/accounts/templates/accounts.html index 0164d43..162d179 100644 --- a/accounts/templates/accounts.html +++ b/accounts/templates/accounts.html @@ -32,7 +32,7 @@ <div class="card-header bg-secondary"> {% endif %} <h5 class="my-0 card-title"> - <a class="card-link text-light" href="{% url 'account' user.id %}"><strong>{{ user.username }}</strong></a> + <a class="card-link text-light" href="{% url 'accounts:account' user.id %}"><strong>{{ user.username }}</strong></a> <a class="card-link text-light float-right" data-toggle="modal" href="#editUser{{ user.id }}" title="{% trans "Edit" %}"> <span class="fa fa-cog"></span> </a> diff --git a/accounts/templates/accounts/change_password_form.html b/accounts/templates/accounts/change_password_form.html index 9dfb6a3..398a048 100644 --- a/accounts/templates/accounts/change_password_form.html +++ b/accounts/templates/accounts/change_password_form.html @@ -7,23 +7,28 @@ {% block title %}{%trans "Change Password" %}{% endblock title %} {% block content %} -<div class="card"> - <div class="card-header"> - <h4 class="card-title">{%trans "Change Password" %}: {{ user }}</h4> - </div> - <div class="card-body"> - <form method="post" id="password-change"> - {% csrf_token %} - {% bootstrap_form form layout='horizontal' %} - </form> - </div> - <div class="card-footer"> - <div class="float-right"> - <a class="btn btn-primary" href="javascript:history.back()">{% icon 'times' %} {% trans "Cancel" %}</a> - <button type="submit" form="password-change" class="btn btn-success"> - {% icon 'check' %} {% trans "Change" %} - </button> +<div class="row"> + <div class="offset-2 col-lg-8"> + <div class="card"> + <div class="card-header"> + <h4 class="card-title">{%trans "Change Password" %}: {{ user }}</h4> + </div> + <div class="card-body"> + <form method="post" id="password-change"> + {% csrf_token %} + {% bootstrap_form form layout='horizontal' %} + </form> + </div> + <div class="card-footer"> + <div class="float-right"> + <a class="btn btn-primary" href="javascript:history.back()">{% icon 'times' %} + {% trans "Cancel" %}</a> + <button type="submit" form="password-change" class="btn btn-success"> + {% icon 'check' %} {% trans "Change" %} + </button> + </div> + </div> </div> </div> </div> -{% endblock content %} +{% endblock content %} \ No newline at end of file diff --git a/accounts/templates/profile.html b/accounts/templates/profile.html index 4d11564..23ab53a 100644 --- a/accounts/templates/profile.html +++ b/accounts/templates/profile.html @@ -1,93 +1,80 @@ {% extends "base.html" %} {% load i18n %} +{% load bootstrap4 %} {% load icons %} {% load tags_fingerprint %} -{% block title %}{% trans "Profile" %}{% endblock %} + +{% block title %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock %} + +{% block page_header %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock page_header %} + {% block content %} - <!-- Page Heading --> - <div class="row"> - <div class="col-lg-12"> - <h2 class="page-header">{% trans "Profile" %}</h2> - </div> - </div> - <!-- /.row --> - - {% include 'errors_block.html' %} - - <div class="row"> - <div class="col-lg-12"> - <h3 class="page-header">{% trans "Edit Profile" %}</h3> +<ul class="nav nav-tabs"> + <li class="nav-item"> + <a class="nav-link active" data-toggle="tab" href="#edit-profile">{% trans "Edit Profile" %}</a> + </li> + <li class="nav-item"> + <a class="nav-link" data-toggle="tab" href="#ssh-keys">{% trans "SSH Keys" %}</a> + </li> +</ul> +<div class="tab-content"> + <div class="tab-pane tab-pane-bordered active" id="edit-profile"> + <div class="card"> + <div class="card-body"> + <form method="post" action="" role="form" aria-label="Edit user info form"> + {% csrf_token %} + {% bootstrap_form profile_form layout='horizontal' %} {% if perms.accounts.change_password %} - <a href="{% url 'change_password' %}" class="ml-3 btn btn-primary">{% icon 'lock' %} {% trans "Change Password" %}</a> + <a href="{% url 'accounts:change_password' %}" class="btn btn-primary"> + {% icon 'lock' %} {% trans "Change Password" %} + </a> {% endif %} - <form method="post" action="" role="form" aria-label="Edit user info form">{% csrf_token %} - <div class="form-group"> - <label class="col-sm-2 col-form-label">{% trans "Login" %}</label> - <div class="col-sm-4"> - <input type="text" class="form-control" value="{{ request.user.username }}" disabled> - </div> - </div> - <div class="form-group bridge_name_form_group_dhcp"> - <label class="col-sm-2 col-form-label">{% trans "Username" %}</label> - <div class="col-sm-4"> - <input type="text" class="form-control" name="username" value="{{ request.user.first_name }}" pattern="[0-9a-zA-Z]+"> - </div> - </div> - <div class="form-group bridge_name_form_group_dhcp"> - <label class="col-sm-2 col-form-label">{% trans "Email" %}</label> - <div class="col-sm-4"> - <input type="email" class="form-control" name="email" value="{{ request.user.email }}"> - </div> - </div> - <div class="form-group"> - <div class="col-sm-offset-2 col-sm-10"> - <button type="submit" class="btn btn-primary">{% trans "Change" %}</button> - </div> - </div> - </form> - <h3 class="page-header">{% trans "SSH Keys" %}</h3> - {% if publickeys %} - <div class="col-lg-12"> - <div class="table-responsive"> - <table class="table table-hover"> - <tbody class="text-center"> - {% for key in publickeys %} - <tr> - <td>{{ key.keyname }} ({% ssh_to_fingerprint key.keypublic %})</td> - <td> - <form action="" method="post" role="form" aria-label="Delete user public key form">{% csrf_token %} - <input type="hidden" name="keyid" value="{{ key.id }}"/> - <button type="submit" class="btn btn-sm btn-secondary" name="keydelete" title="{% trans "Delete" %}" onclick="return confirm('{% trans "Are you sure?" %}')"> - <span class="fa fa-trash"></span> - </button> - </form> - </td> - </tr> - {% endfor %} - </tbody> - </table> - </div> - </div> - {% endif %} - <form method="post" action="" role="form" aria-label="Add key to user form">{% csrf_token %} - <div class="form-group bridge_name_form_group_dhcp"> - <label class="col-sm-2 col-form-label">{% trans "Key name" %}</label> - <div class="col-sm-4"> - <input type="text" class="form-control" name="keyname" placeholder="{% trans "Enter Name" %}"> - </div> - </div> - <div class="form-group bridge_name_form_group_dhcp"> - <label class="col-sm-2 col-form-label">{% trans "Public key" %}</label> - <div class="col-sm-8"> - <textarea name="keypublic" class="form-control" rows="6" placeholder="{% trans "Enter Public Key" %}"></textarea> - </div> - </div> - <div class="form-group"> - <div class="col-sm-10"> - <button type="submit" class="btn btn-primary">{% trans "Add" %}</button> - </div> - </div> - </form> - </div> + <div class="form-group mb-0 float-right"> + <button type="submit" class="btn btn-primary"> + {% icon 'pencil' %} {% trans "Update" %} + </button> + </div> + </form> </div> -{% endblock %} + </div> + </div> + <div class="tab-pane tab-pane-bordered fade" id="ssh-keys"> + {% if publickeys %} + <div class="col-lg-12"> + <div class="table-responsive"> + <table class="table table-hover"> + <tbody class="text-center"> + {% for key in publickeys %} + <tr> + <td>{{ key.keyname }} ({% ssh_to_fingerprint key.keypublic %})</td> + <td> + <a href="{% url 'accounts:ssh_key_delete' key.id %}" title="{% trans "Delete" %}" class="btn btn-sm btn-secondary"> + {% icon 'trash' %} + </a> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + {% endif %} + <div class="card"> + <div class="card-header"> + {%trans "Add SSH Key" %} + </div> + <div class="card-body"> + <form method="post" action="{% url 'accounts:ssh_key_create' %}" role="form" aria-label="Add key to user form"> + {% csrf_token %} + {% bootstrap_form ssh_key_form layout='horizontal' %} + <div class="form-group mb-0 float-right"> + <button type="submit" class="btn btn-primary"> + {% icon 'plus' %} {% trans "Add" %} + </button> + </div> + </form> + </div> + </div> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/accounts/tests.py b/accounts/tests.py index 051eb0a..2419990 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1,45 +1,111 @@ -from django.contrib.auth.models import Permission, User +from appsettings.settings import app_settings +from computes.models import Compute +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission from django.shortcuts import reverse from django.test import Client, TestCase +from instances.models import Instance +from instances.utils import refr +from libvirt import VIR_DOMAIN_UNDEFINE_NVRAM +from vrtManager.create import wvmCreate + +from accounts.forms import UserInstanceForm, UserSSHKeyForm +from accounts.models import UserInstance, UserSSHKey +from accounts.utils import validate_ssh_key class AccountsTestCase(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Add users for testing purposes + User = get_user_model() + cls.admin_user = User.objects.get(pk=1) + cls.test_user = User.objects.create_user(username='test', password='test') + + # Add localhost compute + cls.compute = Compute( + name='test-compute', + hostname='localhost', + login='', + password='', + details='local', + type=4, + ) + cls.compute.save() + + cls.connection = wvmCreate( + cls.compute.hostname, + cls.compute.login, + cls.compute.password, + cls.compute.type, + ) + + # Add disks for testing + cls.connection.create_volume( + 'default', + 'test-volume', + 1, + 'qcow2', + False, + 0, + 0, + ) + + # XML for testing vm + with open('conf/test-vm.xml', 'r') as f: + cls.xml = f.read() + + # Create testing vm from XML + cls.connection._defineXML(cls.xml) + refr(cls.compute) + cls.instance = Instance.objects.get(pk=1) + + @classmethod + def tearDownClass(cls): + # Destroy testing vm + cls.instance.proxy.delete_all_disks() + cls.instance.proxy.delete(VIR_DOMAIN_UNDEFINE_NVRAM) + super().tearDownClass() + def setUp(self): self.client.login(username='admin', password='admin') - user = User.objects.create_user(username='test', password='test') permission = Permission.objects.get(codename='change_password') - user.user_permissions.add(permission) + self.test_user.user_permissions.add(permission) + self.rsa_key = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfxYHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw== test@test' + self.ecdsa_key = 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJc5xpT3R0iFJYNZbmWgAiDlHquX/BcV1kVTsnBfiMsZgU3lGaqz2eb7IBcir/dxGnsVENTTmPQ6sNcxLxT9kkQ= realgecko@archlinux' def test_profile(self): - response = self.client.get(reverse('profile')) + response = self.client.get(reverse('accounts:profile')) self.assertEqual(response.status_code, 200) - response = self.client.get(reverse('account', args=[2])) + response = self.client.get(reverse('accounts:account', args=[self.test_user.id])) + self.assertEqual(response.status_code, 200) + + def test_account_with_otp(self): + settings.OTP_ENABLED = True + response = self.client.get(reverse('accounts:account', args=[self.test_user.id])) self.assertEqual(response.status_code, 200) def test_login_logout(self): - user = User.objects.get(username='test') - self.assertEqual(user.id, 2) - client = Client() - response = client.post(reverse("login"), {"username": "test", "password": "test"}) - self.assertRedirects(response, reverse('profile')) + response = client.post(reverse("accounts:login"), {"username": "test", "password": "test"}) + self.assertRedirects(response, reverse('accounts:profile')) - response = client.get(reverse('logout')) - self.assertRedirects(response, reverse('login')) + response = client.get(reverse('accounts:logout')) + self.assertRedirects(response, reverse('accounts:login')) - def test_password_change(self): - client = Client() + def test_change_password(self): + self.client.force_login(self.test_user) - logged_in = client.login(username='test', password='test') - self.assertTrue(logged_in) - - response = client.get(reverse('change_password')) + response = self.client.get(reverse('accounts:change_password')) self.assertEqual(response.status_code, 200) - response = client.post( - reverse('change_password'), + response = self.client.post( + reverse('accounts:change_password'), { 'old_password': 'wrongpass', 'new_password1': 'newpw', @@ -48,17 +114,154 @@ class AccountsTestCase(TestCase): ) self.assertEqual(response.status_code, 200) - response = client.post( - reverse('change_password'), + response = self.client.post( + reverse('accounts:change_password'), { 'old_password': 'test', 'new_password1': 'newpw', 'new_password2': 'newpw', }, ) - self.assertRedirects(response, reverse('profile')) + self.assertRedirects(response, reverse('accounts:profile')) - client.logout() + self.client.logout() - logged_in = client.login(username='test', password='newpw') + logged_in = self.client.login(username='test', password='newpw') self.assertTrue(logged_in) + + def test_user_instance_create_update_delete(self): + # create + response = self.client.get(reverse('accounts:user_instance_create', args=[self.test_user.id])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse('accounts:user_instance_create', args=[self.test_user.id]), + { + 'user': self.test_user.id, + 'instance': self.instance.id, + 'is_change': False, + 'is_delete': False, + 'is_vnc': False, + }, + ) + self.assertRedirects(response, reverse('accounts:account', args=[self.test_user.id])) + + user_instance: UserInstance = UserInstance.objects.get(pk=1) + self.assertEqual(user_instance.user, self.test_user) + self.assertEqual(user_instance.instance, self.instance) + self.assertEqual(user_instance.is_change, False) + self.assertEqual(user_instance.is_delete, False) + self.assertEqual(user_instance.is_vnc, False) + + # update + response = self.client.get(reverse('accounts:user_instance_update', args=[user_instance.id])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse('accounts:user_instance_update', args=[user_instance.id]), + { + 'user': self.test_user.id, + 'instance': self.instance.id, + 'is_change': True, + 'is_delete': True, + 'is_vnc': True, + }, + ) + self.assertRedirects(response, reverse('accounts:account', args=[self.test_user.id])) + + user_instance: UserInstance = UserInstance.objects.get(pk=1) + self.assertEqual(user_instance.user, self.test_user) + self.assertEqual(user_instance.instance, self.instance) + self.assertEqual(user_instance.is_change, True) + self.assertEqual(user_instance.is_delete, True) + self.assertEqual(user_instance.is_vnc, True) + + # delete + response = self.client.get(reverse('accounts:user_instance_delete', args=[user_instance.id])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse('accounts:user_instance_delete', args=[user_instance.id])) + self.assertRedirects(response, reverse('accounts:account', args=[self.test_user.id])) + + # test 'next' redirect during deletion + user_instance = UserInstance.objects.create(user=self.test_user, instance=self.instance) + response = self.client.post( + reverse('accounts:user_instance_delete', args=[user_instance.id]) + '?next=' + reverse('index')) + self.assertRedirects(response, reverse('index')) + + def test_update_user_profile(self): + self.client.force_login(self.test_user) + + user = get_user_model().objects.get(username='test') + self.assertEqual(user.first_name, '') + self.assertEqual(user.last_name, '') + self.assertEqual(user.email, '') + + response = self.client.post(reverse('accounts:profile'), { + 'first_name': 'first name', + 'last_name': 'last name', + 'email': 'email@mail.mail', + }) + self.assertRedirects(response, reverse('accounts:profile')) + + user = get_user_model().objects.get(username='test') + self.assertEqual(user.first_name, 'first name') + self.assertEqual(user.last_name, 'last name') + self.assertEqual(user.email, 'email@mail.mail') + + def test_create_delete_ssh_key(self): + response = self.client.get(reverse('accounts:ssh_key_create')) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse('accounts:ssh_key_create'), { + 'keyname': 'keyname', + 'keypublic': self.rsa_key, + }) + self.assertRedirects(response, reverse('accounts:profile')) + + key = UserSSHKey.objects.get(pk=1) + self.assertEqual(key.keyname, 'keyname') + self.assertEqual(key.keypublic, self.rsa_key) + + response = self.client.get(reverse('accounts:ssh_key_delete', args=[1])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse('accounts:ssh_key_delete', args=[1])) + self.assertRedirects(response, reverse('accounts:profile')) + + def test_validate_ssh_key(self): + self.assertFalse(validate_ssh_key('')) + self.assertFalse(validate_ssh_key('ssh-rsa ABBA test@test')) + self.assertFalse(validate_ssh_key('ssh-rsa AAAABwdzZGY= test@test')) + self.assertFalse(validate_ssh_key('ssh-rsa AAA test@test')) + # validate ecdsa key + self.assertTrue(validate_ssh_key(self.ecdsa_key)) + + def test_forms(self): + # raise available validation errors for maximum coverage + form = UserSSHKeyForm({'keyname': 'keyname', 'keypublic': self.rsa_key}, user=self.test_user) + form.save() + + form = UserSSHKeyForm({'keyname': 'keyname', 'keypublic': self.rsa_key}, user=self.test_user) + self.assertFalse(form.is_valid()) + + form = UserSSHKeyForm({'keyname': 'keyname', 'keypublic': 'invalid key'}, user=self.test_user) + self.assertFalse(form.is_valid()) + + app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER = 'False' + form = UserInstanceForm({ + 'user': self.admin_user.id, + 'instance': self.instance.id, + 'is_change': False, + 'is_delete': False, + 'is_vnc': False, + }) + form.save() + form = UserInstanceForm({ + 'user': self.test_user.id, + 'instance': self.instance.id, + 'is_change': False, + 'is_delete': False, + 'is_vnc': False, + }) + self.assertFalse(form.is_valid()) diff --git a/accounts/urls.py b/accounts/urls.py index 01f2408..e7a0d13 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -5,6 +5,8 @@ from django_otp.forms import OTPAuthenticationForm from . import views +app_name = 'accounts' + urlpatterns = [ path('logout/', LogoutView.as_view(template_name='logout.html'), name='logout'), path('profile/', views.profile, name='profile'), @@ -13,6 +15,8 @@ urlpatterns = [ path('user_instance/create/<int:user_id>/', views.user_instance_create, name='user_instance_create'), path('user_instance/<int:pk>/update/', views.user_instance_update, name='user_instance_update'), path('user_instance/<int:pk>/delete/', views.user_instance_delete, name='user_instance_delete'), + path('ssh_key/create/', views.ssh_key_create, name='ssh_key_create'), + path('ssh_key/<int:pk>/delete/', views.ssh_key_delete, name='ssh_key_delete'), ] if settings.OTP_ENABLED: diff --git a/accounts/utils.py b/accounts/utils.py index 4e003bf..12cf2c1 100644 --- a/accounts/utils.py +++ b/accounts/utils.py @@ -1,3 +1,7 @@ +import base64 +import binascii +import struct + from django_otp import devices_for_user from django_otp.plugins.otp_totp.models import TOTPDevice @@ -7,3 +11,29 @@ def get_user_totp_device(user): for device in devices: if isinstance(device, TOTPDevice): return device + + +def validate_ssh_key(key): + array = key.encode().split() + # Each rsa-ssh key has 3 different strings in it, first one being + # typeofkey second one being keystring third one being username . + if len(array) != 3: + return False + typeofkey = array[0] + string = array[1] + username = array[2] + # must have only valid rsa-ssh key characters ie binascii characters + try: + data = base64.decodestring(string) + except binascii.Error: + return False + # unpack the contents of data, from data[:4] , property of ssh key . + try: + str_len = struct.unpack('>I', data[:4])[0] + except struct.error: + return False + # data[4:str_len] must have string which matches with the typeofkey, another ssh key property. + if data[4:4 + str_len] == typeofkey: + return True + else: + return False diff --git a/accounts/views.py b/accounts/views.py index 5bfe73e..7226f45 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,14 +1,15 @@ from admin.decorators import superuser_only +from django.conf import settings from django.contrib import messages from django.contrib.auth import update_session_auth_hash from django.contrib.auth.decorators import permission_required from django.contrib.auth.forms import PasswordChangeForm -from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from instances.models import Instance +from accounts.forms import ProfileForm, UserSSHKeyForm from accounts.models import * from . import forms @@ -16,49 +17,50 @@ from .utils import get_user_totp_device def profile(request): - error_messages = [] publickeys = UserSSHKey.objects.filter(user_id=request.user.id) + profile_form = ProfileForm(request.POST or None, instance=request.user) + ssh_key_form = UserSSHKeyForm() - if request.method == "POST": - if "username" in request.POST: - username = request.POST.get("username", "") - email = request.POST.get("email", "") - request.user.first_name = username - request.user.email = email - request.user.save() - return HttpResponseRedirect(request.get_full_path()) - if "keyname" in request.POST: - keyname = request.POST.get("keyname", "") - keypublic = request.POST.get("keypublic", "") - for key in publickeys: - if keyname == key.keyname: - msg = _("Key name already exist") - error_messages.append(msg) - if keypublic == key.keypublic: - msg = _("Public key already exist") - error_messages.append(msg) - if "\n" in keypublic or "\r" in keypublic: - msg = _("Invalid characters in public key") - error_messages.append(msg) - if not error_messages: - addkeypublic = UserSSHKey( - user_id=request.user.id, - keyname=keyname, - keypublic=keypublic, - ) - addkeypublic.save() - return HttpResponseRedirect(request.get_full_path()) - if "keydelete" in request.POST: - keyid = request.POST.get("keyid", "") - delkeypublic = UserSSHKey.objects.get(id=keyid) - delkeypublic.delete() - return HttpResponseRedirect(request.get_full_path()) - return render(request, "profile.html", locals()) + if profile_form.is_valid(): + profile_form.save() + messages.success(request, _('Profile updated')) + return redirect('accounts:profile') + + return render(request, "profile.html", { + 'publickeys': publickeys, + 'profile_form': profile_form, + 'ssh_key_form': ssh_key_form, + }) + + +def ssh_key_create(request): + key_form = UserSSHKeyForm(request.POST or None, user=request.user) + if key_form.is_valid(): + key_form.save() + messages.success(request, _('SSH key added')) + return redirect('accounts:profile') + + return render(request, 'common/form.html', { + 'form': key_form, + 'title': _('Add SSH key'), + }) + + +def ssh_key_delete(request, pk): + ssh_key = get_object_or_404(UserSSHKey, pk=pk, user=request.user) + if request.method == 'POST': + ssh_key.delete() + messages.success(request, _('SSH key deleted')) + return redirect('accounts:profile') + + return render(request, 'common/confirm_delete.html', { + 'object': ssh_key, + 'title': _('Delete SSH key'), + }) @superuser_only def account(request, user_id): - error_messages = [] user = User.objects.get(id=user_id) user_insts = UserInstance.objects.filter(user_id=user_id) instances = Instance.objects.all().order_by("name") @@ -74,17 +76,14 @@ def account(request, user_id): @permission_required("accounts.change_password", raise_exception=True) def change_password(request): - if request.method == "POST": - form = PasswordChangeForm(request.user, request.POST) - if form.is_valid(): - user = form.save() - update_session_auth_hash(request, user) # Important! - messages.success(request, _("Password Changed")) - return redirect("profile") - else: - messages.error(request, _("Wrong Data Provided")) - else: - form = PasswordChangeForm(request.user) + form = PasswordChangeForm(request.user, request.POST or None) + + if form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) # Important! + messages.success(request, _("Password Changed")) + return redirect("accounts:profile") + return render(request, "accounts/change_password_form.html", {"form": form}) @@ -95,7 +94,7 @@ def user_instance_create(request, user_id): form = forms.UserInstanceForm(request.POST or None, initial={"user": user}) if form.is_valid(): form.save() - return redirect(reverse("account", args=[user.id])) + return redirect(reverse("accounts:account", args=[user.id])) return render( request, @@ -113,7 +112,7 @@ def user_instance_update(request, pk): form = forms.UserInstanceForm(request.POST or None, instance=user_instance) if form.is_valid(): form.save() - return redirect(reverse("account", args=[user_instance.user.id])) + return redirect(reverse("accounts:account", args=[user_instance.user.id])) return render( request, @@ -135,7 +134,7 @@ def user_instance_delete(request, pk): if next: return redirect(next) else: - return redirect(reverse("account", args=[user.id])) + return redirect(reverse("accounts:account", args=[user.id])) return render( request, diff --git a/admin/templates/admin/user_list.html b/admin/templates/admin/user_list.html index 1e544f1..746e726 100644 --- a/admin/templates/admin/user_list.html +++ b/admin/templates/admin/user_list.html @@ -57,7 +57,7 @@ <td>{% if can_clone %}{% icon 'check' %}{% endif %}</td> <td> <div class="float-right btn-group"> - <a class="btn btn-success" title="{%trans "View Profile" %}" href="{% url 'account' user.id %}">{% icon 'eye' %}</a> + <a class="btn btn-success" title="{%trans "View Profile" %}" href="{% url 'accounts:account' user.id %}">{% icon 'eye' %}</a> <a class="btn btn-primary" title="{%trans "Edit" %}" href="{% url 'admin:user_update' user.id %}">{% icon 'pencil' %}</a> {% if user.is_active %} <a class="btn btn-warning" title="{%trans "Block" %}" href="{% url 'admin:user_block' user.id %}">{% icon 'stop' %}</a> diff --git a/appsettings/settings.py b/appsettings/settings.py index c63a9f0..d575f37 100644 --- a/appsettings/settings.py +++ b/appsettings/settings.py @@ -1,7 +1,7 @@ from .models import AppSettings -class Settings(object): +class Settings: pass diff --git a/conf/test-vm.xml b/conf/test-vm.xml new file mode 100644 index 0000000..c407266 --- /dev/null +++ b/conf/test-vm.xml @@ -0,0 +1,120 @@ +<domain type="kvm"> + <name>test-vm</name> + <uuid>1bd3c1f2-dd12-4b8d-a298-dff387cb9f93</uuid> + <description>None</description> + <memory unit="KiB">131072</memory> + <currentMemory unit="KiB">131072</currentMemory> + <vcpu placement="static">1</vcpu> + <os> + <type arch="x86_64" machine="pc-q35-5.1">hvm</type> + <boot dev="hd" /> + <boot dev="cdrom" /> + <bootmenu enable="yes" /> + </os> + <features> + <acpi /> + <apic /> + </features> + <cpu mode="host-model" check="partial" /> + <clock offset="utc" /> + <on_poweroff>destroy</on_poweroff> + <on_reboot>restart</on_reboot> + <on_crash>restart</on_crash> + <devices> + <emulator>/usr/bin/qemu-system-x86_64</emulator> + <disk type="file" device="disk"> + <driver name="qemu" type="qcow2" cache="directsync" /> + <source file="/var/lib/libvirt/images/test-volume.qcow2" /> + <target dev="vda" bus="virtio" /> + <address type="pci" domain="0x0000" bus="0x03" slot="0x00" function="0x0" /> + </disk> + <disk type="file" device="cdrom"> + <driver name="qemu" type="raw" /> + <target dev="sda" bus="sata" /> + <readonly /> + <address type="drive" controller="0" bus="0" target="0" unit="0" /> + </disk> + <controller type="usb" index="0" model="qemu-xhci"> + <address type="pci" domain="0x0000" bus="0x02" slot="0x00" function="0x0" /> + </controller> + <controller type="sata" index="0"> + <address type="pci" domain="0x0000" bus="0x00" slot="0x1f" function="0x2" /> + </controller> + <controller type="pci" index="0" model="pcie-root" /> + <controller type="pci" index="1" model="pcie-root-port"> + <model name="pcie-root-port" /> + <target chassis="1" port="0x10" /> + <address type="pci" domain="0x0000" bus="0x00" slot="0x02" function="0x0" multifunction="on" /> + </controller> + <controller type="pci" index="2" model="pcie-root-port"> + <model name="pcie-root-port" /> + <target chassis="2" port="0x11" /> + <address type="pci" domain="0x0000" bus="0x00" slot="0x02" function="0x1" /> + </controller> + <controller type="pci" index="3" model="pcie-root-port"> + <model name="pcie-root-port" /> + <target chassis="3" port="0x12" /> + <address type="pci" domain="0x0000" bus="0x00" slot="0x02" function="0x2" /> + </controller> + <controller type="pci" index="4" model="pcie-root-port"> + <model name="pcie-root-port" /> + <target chassis="4" port="0x13" /> + <address type="pci" domain="0x0000" bus="0x00" slot="0x02" function="0x3" /> + </controller> + <controller type="pci" index="5" model="pcie-root-port"> + <model name="pcie-root-port" /> + <target chassis="5" port="0x14" /> + <address type="pci" domain="0x0000" bus="0x00" slot="0x02" function="0x4" /> + </controller> + <controller type="pci" index="6" model="pcie-root-port"> + <model name="pcie-root-port" /> + <target chassis="6" port="0x15" /> + <address type="pci" domain="0x0000" bus="0x00" slot="0x02" function="0x5" /> + </controller> + <controller type="pci" index="7" model="pcie-root-port"> + <model name="pcie-root-port" /> + <target chassis="7" port="0x16" /> + <address type="pci" domain="0x0000" bus="0x00" slot="0x02" function="0x6" /> + </controller> + <controller type="pci" index="8" model="pcie-root-port"> + <model name="pcie-root-port" /> + <target chassis="8" port="0x17" /> + <address type="pci" domain="0x0000" bus="0x00" slot="0x02" function="0x7" /> + </controller> + <interface type="network"> + <mac address="52:54:00:a2:3c:e7" /> + <source network="default" /> + <model type="virtio" /> + <address type="pci" domain="0x0000" bus="0x01" slot="0x00" function="0x0" /> + </interface> + <serial type="pty"> + <target type="isa-serial" port="0"> + <model name="isa-serial" /> + </target> + </serial> + <console type="pty"> + <target type="serial" port="0" /> + </console> + <input type="mouse" bus="virtio"> + <address type="pci" domain="0x0000" bus="0x05" slot="0x00" function="0x0" /> + </input> + <input type="keyboard" bus="virtio"> + <address type="pci" domain="0x0000" bus="0x06" slot="0x00" function="0x0" /> + </input> + <input type="tablet" bus="virtio"> + <address type="pci" domain="0x0000" bus="0x07" slot="0x00" function="0x0" /> + </input> + <input type="mouse" bus="ps2" /> + <input type="keyboard" bus="ps2" /> + <graphics type="spice" autoport="yes" listen="0.0.0.0"> + <listen type="address" address="0.0.0.0" /> + </graphics> + <video> + <model type="vga" vram="16384" heads="1" primary="yes" /> + <address type="pci" domain="0x0000" bus="0x00" slot="0x01" function="0x0" /> + </video> + <memballoon model="virtio"> + <address type="pci" domain="0x0000" bus="0x04" slot="0x00" function="0x0" /> + </memballoon> + </devices> +</domain> diff --git a/instances/models.py b/instances/models.py index 34d7e19..563a5e1 100644 --- a/instances/models.py +++ b/instances/models.py @@ -204,6 +204,10 @@ class Instance(models.Model): def formats(self): return self.proxy.get_image_formats() + @cached_property + def interfaces(self): + return self.proxy.get_ifaces() + class PermissionSet(models.Model): """ @@ -211,8 +215,9 @@ class PermissionSet(models.Model): """ class Meta: default_permissions = () - permissions = [('clone_instances', 'Can clone instances'), - ('passwordless_console', _('Can access console without password')), - ] + permissions = [ + ('clone_instances', 'Can clone instances'), + ('passwordless_console', _('Can access console without password')), + ] managed = False diff --git a/instances/templates/add_instance_network_block.html b/instances/templates/add_instance_network_block.html index 724ca80..6d82fd5 100644 --- a/instances/templates/add_instance_network_block.html +++ b/instances/templates/add_instance_network_block.html @@ -27,7 +27,7 @@ {% for c_net in networks_host %} <option value="net:{{ c_net }}">Network {{ c_net }}</option> {% endfor %} - {% for c_iface in interfaces_host %} + {% for c_iface in instance.interfaces %} <option value="iface:{{ c_iface }}">Interface {{ c_iface }}</option> {% endfor %} </select> diff --git a/instances/templates/instances/settings_tab.html b/instances/templates/instances/settings_tab.html index 62d6c5a..caec28f 100644 --- a/instances/templates/instances/settings_tab.html +++ b/instances/templates/instances/settings_tab.html @@ -393,7 +393,7 @@ {% for c_net in networks_host %} <option value="net:{{ c_net }}" {% if c_net == network.nic %} selected {% endif %}>{% trans 'Network' %} {{ c_net }}</option> {% endfor %} - {% for c_iface in interfaces_host %} + {% for c_iface in instance.interfaces %} <option value="iface:{{ c_iface }}" {% if c_iface == network.nic %} selected {% endif %}>{% trans 'Interface' %} {{ c_iface }}</option> {% endfor %} </select> @@ -657,9 +657,9 @@ <tbody class="searchable"> {% for userinstance in userinstances %} <tr> - <td><a href="{% url 'account' userinstance.user.id %}">{{ userinstance.user }}</a></td> + <td><a href="{% url 'accounts:account' userinstance.user.id %}">{{ userinstance.user }}</a></td> <td style="width:30px;"> - <a href="{% url 'user_instance_delete' userinstance.id %}?next={% url 'instances:instance' instance.id %}#users"> + <a href="{% url 'accounts:user_instance_delete' userinstance.id %}?next={% url 'instances:instance' instance.id %}#users"> {% icon 'trash' %} </a> </td> diff --git a/instances/tests.py b/instances/tests.py index bcdf9ea..3dac112 100644 --- a/instances/tests.py +++ b/instances/tests.py @@ -116,7 +116,7 @@ class InstancesTestCase(TestCase): self.assertEqual(instance.disks[0]['size'], 1024**3) response = self.client.post(reverse('instances:resize_disk', args=[instance.id]), { - 'disk_size_vda': '2.0 GB', + 'disk_size_vda': '2', }) self.assertRedirects(response, reverse('instances:instance', args=[instance.id]) + '#resize') @@ -449,9 +449,8 @@ class InstancesTestCase(TestCase): response = self.client.post( reverse('instances:destroy', args=[instance.id]), {'delete_disk': True}, - HTTP_REFERER=reverse('index'), ) - self.assertRedirects(response, reverse('instances', args=[compute.id])) + self.assertRedirects(response, reverse('instances:index')) # # create volume # response = self.client.post( diff --git a/templates/common/confirm_delete.html b/templates/common/confirm_delete.html index 015e46c..6f4a42c 100644 --- a/templates/common/confirm_delete.html +++ b/templates/common/confirm_delete.html @@ -3,17 +3,25 @@ {% load icons %} {% load i18n %} -{% block title %}{%trans "Delete" %}{% endblock %} +{% block title %}{{ title }}{% endblock %} + +{% block page_header %}{{ title }}{% endblock page_header %} {% block content %} -<form method="post"> - {% csrf_token %} - <div class="alert alert-warning"> - {%trans "Are you sure you want to delete" %} "{{ object }}"? +<div class="row"> + <div class="offset-3 col-6"> + <form method="post"> + {% csrf_token %} + <div class="alert alert-warning"> + {%trans "Are you sure you want to delete" %} "{{ object }}"? + </div> + <div class="form-group mb-0 float-right"> + <a class="btn btn-primary" href="javascript:history.back()">{% icon 'times' %} {% trans "Cancel" %}</a> + <button type="submit" class="btn btn-danger"> + {% icon 'trash' %} {% trans "Delete" %} + </button> + </div> + </form> </div> - <a class="btn btn-primary" href="javascript:history.back()">{% icon 'times' %} {% trans "Cancel" %}</a> - <button type="submit" class="btn btn-danger"> - {% icon 'check' %} {% trans "Delete" %} - </button> -</form> +</div> {% endblock %} \ No newline at end of file diff --git a/templates/navbar.html b/templates/navbar.html index c1881fa..106d366 100644 --- a/templates/navbar.html +++ b/templates/navbar.html @@ -42,9 +42,9 @@ <a class="dropdown-item disabled" href="#"> {% trans "Language" %}: <span class="badge badge-secondary">{{ LANGUAGE_CODE }}</span> </a> - <a class="dropdown-item {% view_active request 'profile' %}" href="{% url 'profile' %}">{% icon 'vcard' %} {% trans "Profile" %}</a> + <a class="dropdown-item {% view_active request 'accounts:profile' %}" href="{% url 'accounts:profile' %}">{% icon 'vcard' %} {% trans "Profile" %}</a> <div class="dropdown-divider"></div> - <a class="dropdown-item" href="{% url 'logout' %}"><i class="fa fa-fw fa-power-off"></i> {% trans "Log Out" %}</a> + <a class="dropdown-item" href="{% url 'accounts:logout' %}"><i class="fa fa-fw fa-power-off"></i> {% trans "Log Out" %}</a> </div> </li> </ul>