diff --git a/rhodecode/apps/my_account/__init__.py b/rhodecode/apps/my_account/__init__.py --- a/rhodecode/apps/my_account/__init__.py +++ b/rhodecode/apps/my_account/__init__.py @@ -56,6 +56,20 @@ def includeme(config): name='my_account_auth_tokens_delete', pattern=ADMIN_PREFIX + '/my_account/auth_tokens/delete') + # my account ssh keys + config.add_route( + name='my_account_ssh_keys', + pattern=ADMIN_PREFIX + '/my_account/ssh_keys') + config.add_route( + name='my_account_ssh_keys_generate', + pattern=ADMIN_PREFIX + '/my_account/ssh_keys/generate') + config.add_route( + name='my_account_ssh_keys_add', + pattern=ADMIN_PREFIX + '/my_account/ssh_keys/new') + config.add_route( + name='my_account_ssh_keys_delete', + pattern=ADMIN_PREFIX + '/my_account/ssh_keys/delete') + # my account emails config.add_route( name='my_account_emails', diff --git a/rhodecode/apps/my_account/tests/test_my_account_ssh_keys.py b/rhodecode/apps/my_account/tests/test_my_account_ssh_keys.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/my_account/tests/test_my_account_ssh_keys.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2010-2017 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + +import pytest + +from rhodecode.model.db import User, UserSshKeys + +from rhodecode.tests import TestController, assert_session_flash +from rhodecode.tests.fixture import Fixture + +fixture = Fixture() + + +def route_path(name, params=None, **kwargs): + import urllib + from rhodecode.apps._base import ADMIN_PREFIX + + base_url = { + 'my_account_ssh_keys': + ADMIN_PREFIX + '/my_account/ssh_keys', + 'my_account_ssh_keys_generate': + ADMIN_PREFIX + '/my_account/ssh_keys/generate', + 'my_account_ssh_keys_add': + ADMIN_PREFIX + '/my_account/ssh_keys/new', + 'my_account_ssh_keys_delete': + ADMIN_PREFIX + '/my_account/ssh_keys/delete', + }[name].format(**kwargs) + + if params: + base_url = '{}?{}'.format(base_url, urllib.urlencode(params)) + return base_url + + +class TestMyAccountSshKeysView(TestController): + INVALID_KEY = """\ + ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDk+77sjDzVeB6vevJsuZds1iNU5 + LANOa5CU5G/9JYIA6RYsWWMO7mbsR82IUckdqOHmxSykfR1D1TdluyIpQLrwgH5kb + n8FkVI8zBMCKakxowvN67B0R7b1BT4PPzW2JlOXei/m9W12ZY484VTow6/B+kf2Q8 + cP8tmCJmKWZma5Em7OTUhvjyQVNz3v7HfeY5Hq0Ci4ECJ59hepFDabJvtAXg9XrI6 + jvdphZTc30I4fG8+hBHzpeFxUGvSGNtXPUbwaAY8j/oHYrTpMgkj6pUEFsiKfC5zP + qPFR5HyKTCHW0nFUJnZsbyFT5hMiF/hZkJc9A0ZbdSvJwCRQ/g3bmdL + your_email@example.com + """ + VALID_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDk+77sjDzVeB6vev' \ + 'JsuZds1iNU5LANOa5CU5G/9JYIA6RYsWWMO7mbsR82IUckdqOHmxSy' \ + 'kfR1D1TdluyIpQLrwgH5kbn8FkVI8zBMCKakxowvN67B0R7b1BT4PP' \ + 'zW2JlOXei/m9W12ZY484VTow6/B+kf2Q8cP8tmCJmKWZma5Em7OTUh' \ + 'vjyQVNz3v7HfeY5Hq0Ci4ECJ59hepFDabJvtAXg9XrI6jvdphZTc30' \ + 'I4fG8+hBHzpeFxUGvSGNtXPUbwaAY8j/oHYrTpMgkj6pUEFsiKfC5zPq' \ + 'PFR5HyKTCHW0nFUJnZsbyFT5hMiF/hZkJc9A0ZbdSvJwCRQ/g3bmdL ' \ + 'your_email@example.com' + + def test_add_ssh_key_error(self, user_util): + user = user_util.create_user(password='qweqwe') + self.log_user(user.username, 'qweqwe') + + key_data = self.INVALID_KEY + + desc = 'MY SSH KEY' + response = self.app.post( + route_path('my_account_ssh_keys_add'), + {'description': desc, 'key_data': key_data, + 'csrf_token': self.csrf_token}) + assert_session_flash(response, 'An error occurred during ssh ' + 'key saving: Unable to decode the key') + + def test_ssh_key_duplicate(self, user_util): + user = user_util.create_user(password='qweqwe') + self.log_user(user.username, 'qweqwe') + key_data = self.VALID_KEY + + desc = 'MY SSH KEY' + response = self.app.post( + route_path('my_account_ssh_keys_add'), + {'description': desc, 'key_data': key_data, + 'csrf_token': self.csrf_token}) + assert_session_flash(response, 'Ssh Key successfully created') + response.follow() # flush session flash + + # add the same key AGAIN + desc = 'MY SSH KEY' + response = self.app.post( + route_path('my_account_ssh_keys_add'), + {'description': desc, 'key_data': key_data, + 'csrf_token': self.csrf_token}) + assert_session_flash(response, 'An error occurred during ssh key ' + 'saving: Such key already exists, ' + 'please use a different one') + + def test_add_ssh_key(self, user_util): + user = user_util.create_user(password='qweqwe') + self.log_user(user.username, 'qweqwe') + + key_data = self.VALID_KEY + + desc = 'MY SSH KEY' + response = self.app.post( + route_path('my_account_ssh_keys_add'), + {'description': desc, 'key_data': key_data, + 'csrf_token': self.csrf_token}) + assert_session_flash(response, 'Ssh Key successfully created') + + response = response.follow() + response.mustcontain(desc) + + def test_delete_ssh_key(self, user_util): + user = user_util.create_user(password='qweqwe') + user_id = user.user_id + self.log_user(user.username, 'qweqwe') + + key_data = self.VALID_KEY + + desc = 'MY SSH KEY' + response = self.app.post( + route_path('my_account_ssh_keys_add'), + {'description': desc, 'key_data': key_data, + 'csrf_token': self.csrf_token}) + assert_session_flash(response, 'Ssh Key successfully created') + response = response.follow() # flush the Session flash + + # now delete our key + keys = UserSshKeys.query().filter(UserSshKeys.user_id == user_id).all() + assert 1 == len(keys) + + response = self.app.post( + route_path('my_account_ssh_keys_delete'), + {'del_ssh_key': keys[0].ssh_key_id, + 'csrf_token': self.csrf_token}) + + assert_session_flash(response, 'Ssh key successfully deleted') + keys = UserSshKeys.query().filter(UserSshKeys.user_id == user_id).all() + assert 0 == len(keys) + + def test_generate_keypair(self, user_util): + user = user_util.create_user(password='qweqwe') + self.log_user(user.username, 'qweqwe') + + response = self.app.get( + route_path('my_account_ssh_keys_generate')) + + response.mustcontain('Private key') + response.mustcontain('Public key') + response.mustcontain('-----BEGIN RSA PRIVATE KEY-----') diff --git a/rhodecode/apps/my_account/views/my_account_ssh_keys.py b/rhodecode/apps/my_account/views/my_account_ssh_keys.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/my_account/views/my_account_ssh_keys.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2016-2017 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + +import logging + +from pyramid.httpexceptions import HTTPFound +from pyramid.view import view_config + +from rhodecode.apps._base import BaseAppView, DataGridAppView +from rhodecode.apps.ssh_support import SshKeyFileChangeEvent +from rhodecode.events import trigger +from rhodecode.lib import helpers as h +from rhodecode.lib import audit_logger +from rhodecode.lib.auth import LoginRequired, NotAnonymous, CSRFRequired +from rhodecode.model.db import IntegrityError, UserSshKeys +from rhodecode.model.meta import Session +from rhodecode.model.ssh_key import SshKeyModel + +log = logging.getLogger(__name__) + + +class MyAccountSshKeysView(BaseAppView, DataGridAppView): + + def load_default_context(self): + c = self._get_local_tmpl_context() + c.user = c.auth_user.get_instance() + self._register_global_c(c) + return c + + @LoginRequired() + @NotAnonymous() + @view_config( + route_name='my_account_ssh_keys', request_method='GET', + renderer='rhodecode:templates/admin/my_account/my_account.mako') + def my_account_ssh_keys(self): + _ = self.request.translate + + c = self.load_default_context() + c.active = 'ssh_keys' + c.default_key = self.request.GET.get('default_key') + c.user_ssh_keys = SshKeyModel().get_ssh_keys(c.user.user_id) + return self._get_template_context(c) + + @LoginRequired() + @NotAnonymous() + @view_config( + route_name='my_account_ssh_keys_generate', request_method='GET', + renderer='rhodecode:templates/admin/my_account/my_account.mako') + def ssh_keys_generate_keypair(self): + _ = self.request.translate + c = self.load_default_context() + + c.active = 'ssh_keys_generate' + comment = 'RhodeCode-SSH {}'.format(c.user.email or '') + c.private, c.public = SshKeyModel().generate_keypair(comment=comment) + c.target_form_url = h.route_path( + 'my_account_ssh_keys', _query=dict(default_key=c.public)) + return self._get_template_context(c) + + @LoginRequired() + @NotAnonymous() + @CSRFRequired() + @view_config( + route_name='my_account_ssh_keys_add', request_method='POST',) + def my_account_ssh_keys_add(self): + _ = self.request.translate + c = self.load_default_context() + + user_data = c.user.get_api_data() + key_data = self.request.POST.get('key_data') + description = self.request.POST.get('description') + + try: + if not key_data: + raise ValueError('Please add a valid public key') + + key = SshKeyModel().parse_key(key_data.strip()) + fingerprint = key.hash_md5() + + ssh_key = SshKeyModel().create( + c.user.user_id, fingerprint, key_data, description) + ssh_key_data = ssh_key.get_api_data() + + audit_logger.store_web( + 'user.edit.ssh_key.add', action_data={ + 'data': {'ssh_key': ssh_key_data, 'user': user_data}}, + user=self._rhodecode_user, ) + Session().commit() + + # Trigger an event on change of keys. + trigger(SshKeyFileChangeEvent(), self.request.registry) + + h.flash(_("Ssh Key successfully created"), category='success') + + except IntegrityError: + log.exception("Exception during ssh key saving") + h.flash(_('An error occurred during ssh key saving: {}').format( + 'Such key already exists, please use a different one'), + category='error') + except Exception as e: + log.exception("Exception during ssh key saving") + h.flash(_('An error occurred during ssh key saving: {}').format(e), + category='error') + + return HTTPFound(h.route_path('my_account_ssh_keys')) + + @LoginRequired() + @NotAnonymous() + @CSRFRequired() + @view_config( + route_name='my_account_ssh_keys_delete', request_method='POST') + def my_account_ssh_keys_delete(self): + _ = self.request.translate + c = self.load_default_context() + + user_data = c.user.get_api_data() + + del_ssh_key = self.request.POST.get('del_ssh_key') + + if del_ssh_key: + ssh_key = UserSshKeys.get_or_404(del_ssh_key) + ssh_key_data = ssh_key.get_api_data() + + SshKeyModel().delete(del_ssh_key, c.user.user_id) + audit_logger.store_web( + 'user.edit.ssh_key.delete', action_data={ + 'data': {'ssh_key': ssh_key_data, 'user': user_data}}, + user=self._rhodecode_user,) + Session().commit() + # Trigger an event on change of keys. + trigger(SshKeyFileChangeEvent(), self.request.registry) + h.flash(_("Ssh key successfully deleted"), category='success') + + return HTTPFound(h.route_path('my_account_ssh_keys')) diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -42,6 +42,7 @@ from sqlalchemy.orm import ( relationship, joinedload, class_mapper, validates, aliased) from sqlalchemy.sql.expression import true from sqlalchemy.sql.functions import coalesce, count # noqa +from sqlalchemy.exc import IntegrityError # noqa from beaker.cache import cache_region from zope.cachedescriptors.property import Lazy as LazyProperty diff --git a/rhodecode/public/js/rhodecode/routes.js b/rhodecode/public/js/rhodecode/routes.js --- a/rhodecode/public/js/rhodecode/routes.js +++ b/rhodecode/public/js/rhodecode/routes.js @@ -221,6 +221,10 @@ function registerRCRoutes() { pyroutes.register('my_account_auth_tokens', '/_admin/my_account/auth_tokens', []); pyroutes.register('my_account_auth_tokens_add', '/_admin/my_account/auth_tokens/new', []); pyroutes.register('my_account_auth_tokens_delete', '/_admin/my_account/auth_tokens/delete', []); + pyroutes.register('my_account_ssh_keys', '/_admin/my_account/ssh_keys', []); + pyroutes.register('my_account_ssh_keys_generate', '/_admin/my_account/ssh_keys/generate', []); + pyroutes.register('my_account_ssh_keys_add', '/_admin/my_account/ssh_keys/new', []); + pyroutes.register('my_account_ssh_keys_delete', '/_admin/my_account/ssh_keys/delete', []); pyroutes.register('my_account_emails', '/_admin/my_account/emails', []); pyroutes.register('my_account_emails_add', '/_admin/my_account/emails/new', []); pyroutes.register('my_account_emails_delete', '/_admin/my_account/emails/delete', []); diff --git a/rhodecode/templates/admin/my_account/my_account.mako b/rhodecode/templates/admin/my_account/my_account.mako --- a/rhodecode/templates/admin/my_account/my_account.mako +++ b/rhodecode/templates/admin/my_account/my_account.mako @@ -29,6 +29,7 @@
  • ${_('Profile')}
  • ${_('Password')}
  • ${_('Auth Tokens')}
  • +
  • ${_('SSH Keys')}
  • ## TODO: Find a better integration of oauth views into navigation. <% my_account_oauth_url = h.route_path_or_none('my_account_oauth') %> % if my_account_oauth_url: diff --git a/rhodecode/templates/admin/my_account/my_account_ssh_keys.mako b/rhodecode/templates/admin/my_account/my_account_ssh_keys.mako new file mode 100644 --- /dev/null +++ b/rhodecode/templates/admin/my_account/my_account_ssh_keys.mako @@ -0,0 +1,78 @@ +
    +
    +

    ${_('SSH Keys')}

    +
    +
    +
    + + + + + + + + %if c.user_ssh_keys: + %for ssh_key in c.user_ssh_keys: + + + + + + + + %endfor + %else: + + %endif +
    ${_('Fingerprint')}${_('Description')}${_('Created')}${_('Action')}
    + ${ssh_key.ssh_key_fingerprint} + ${ssh_key.description}${h.format_date(ssh_key.created_on)} + ${h.secure_form(h.route_path('my_account_ssh_keys_delete'), method='POST', request=request)} + ${h.hidden('del_ssh_key', ssh_key.ssh_key_id)} + + ${h.end_form()} +
    ${_('No additional ssh keys specified')}
    +
    + +
    + ${h.secure_form(h.route_path('my_account_ssh_keys_add'), method='POST', request=request)} +
    + +
    +
    +
    + +
    +
    + ${h.text('description', class_='medium', placeholder=_('Description'))} + ${_('Generate random RSA key')} +
    +
    + +
    +
    + ${h.textarea('key_data',c.default_key, size=30, placeholder=_("Public key, begins with 'ssh-rsa', 'ssh-dss', 'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', or 'ecdsa-sha2-nistp521'"))} +
    +
    + +
    + ${h.submit('save',_('Add'),class_="btn")} + ${h.reset('reset',_('Reset'),class_="btn")} +
    +
    +
    + ${h.end_form()} +
    +
    +
    + + diff --git a/rhodecode/templates/admin/my_account/my_account_ssh_keys_generate.mako b/rhodecode/templates/admin/my_account/my_account_ssh_keys_generate.mako new file mode 100644 --- /dev/null +++ b/rhodecode/templates/admin/my_account/my_account_ssh_keys_generate.mako @@ -0,0 +1,2 @@ +## share the same template, it's very simple +<%include file='/admin/users/user_edit_ssh_keys_generate.mako'/> \ No newline at end of file diff --git a/rhodecode/templates/admin/users/user_edit_ssh_keys_generate.mako b/rhodecode/templates/admin/users/user_edit_ssh_keys_generate.mako --- a/rhodecode/templates/admin/users/user_edit_ssh_keys_generate.mako +++ b/rhodecode/templates/admin/users/user_edit_ssh_keys_generate.mako @@ -30,9 +30,12 @@ chmod 0600 ~/.ssh/id_rsa_rhodecode_acces

    - ${_('Add this generated key')} + % if hasattr(c, 'target_form_url'): + ${_('Add this generated key')} + % else: + ${_('Add this generated key')} + % endif

    -