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')}
+
+
+
+
+
+ ${_('Fingerprint')} |
+ ${_('Description')} |
+ ${_('Created')} |
+ ${_('Action')} |
+
+ %if c.user_ssh_keys:
+ %for ssh_key in c.user_ssh_keys:
+
+
+ ${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()}
+ |
+
+ %endfor
+ %else:
+ ${_('No additional ssh keys specified')} |
+ %endif
+
+
+
+
+ ${h.secure_form(h.route_path('my_account_ssh_keys_add'), method='POST', request=request)}
+
+ ${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
-