Commit 96bbc98d authored by Manoj M J's avatar Manoj M J Committed by Kushal Pandya

Introduce expiry date for SSH keys

This change introduces expiry dates
for SSH keys.
parent c943774f
...@@ -118,6 +118,14 @@ ...@@ -118,6 +118,14 @@
} }
} }
.ssh-keys-list {
.last-used-at,
.expires,
.key-created-at {
line-height: 32px;
}
}
.key-created-at { .key-created-at {
line-height: 42px; line-height: 42px;
} }
......
...@@ -55,6 +55,6 @@ class Profiles::KeysController < Profiles::ApplicationController ...@@ -55,6 +55,6 @@ class Profiles::KeysController < Profiles::ApplicationController
private private
def key_params def key_params
params.require(:key).permit(:title, :key) params.require(:key).permit(:title, :key, :expires_at)
end end
end end
...@@ -6,6 +6,7 @@ class Key < ApplicationRecord ...@@ -6,6 +6,7 @@ class Key < ApplicationRecord
include AfterCommitQueue include AfterCommitQueue
include Sortable include Sortable
include Sha256Attribute include Sha256Attribute
include Expirable
sha256_attribute :fingerprint_sha256 sha256_attribute :fingerprint_sha256
......
...@@ -6,10 +6,15 @@ ...@@ -6,10 +6,15 @@
= f.label :key, s_('Profiles|Key'), class: 'label-bold' = f.label :key, s_('Profiles|Key'), class: 'label-bold'
%p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Don't use your private SSH key.") %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Don't use your private SSH key.")
= f.text_area :key, class: "form-control js-add-ssh-key-validation-input qa-key-public-key-field", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-ed25519 …" or "ssh-rsa …"') = f.text_area :key, class: "form-control js-add-ssh-key-validation-input qa-key-public-key-field", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-ed25519 …" or "ssh-rsa …"')
.form-group .form-row
= f.label :title, _('Title'), class: 'label-bold' .col.form-group
= f.text_field :title, class: "form-control input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key') = f.label :title, _('Title'), class: 'label-bold'
%p.form-text.text-muted= _('Name your individual key via a title') = f.text_field :title, class: "form-control input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key')
%p.form-text.text-muted= s_('Profiles|Give your individual key a title')
.col.form-group
= f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold'
= f.date_field :expires_at, class: "form-control input-lg qa-key-expiry-field", min: Date.tomorrow
.js-add-ssh-key-validation-warning.hide .js-add-ssh-key-validation-warning.hide
.bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' } .bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' }
......
%li.key-list-item %li.d-flex.align-items-center.key-list-item
.float-left.append-right-10 .append-right-10
- if key.valid? - if key.valid?
= icon 'key', class: 'settings-list-icon d-none d-sm-block' - if key.expired?
%span.d-inline-block.has-tooltip{ title: s_('Profiles|Your key has expired') }
= sprite_icon('warning-solid', size: 16, css_class: 'settings-list-icon d-none d-sm-block')
- else
= sprite_icon('key', size: 16, css_class: 'settings-list-icon d-none d-sm-block ')
- else - else
= icon 'exclamation-triangle', class: 'settings-list-icon d-none d-sm-block has-tooltip', %span.d-inline-block.has-tooltip{ title: key.errors.full_messages.join(', ') }
title: key.errors.full_messages.join(', ') = sprite_icon('warning-solid', size: 16, css_class: 'settings-list-icon d-none d-sm-block')
.key-list-item-info.w-100.float-none
.key-list-item-info
= link_to path_to_key(key, is_admin), class: "title" do = link_to path_to_key(key, is_admin), class: "title" do
= key.title = key.title
%span.text-truncate %span.text-truncate
= key.fingerprint = key.fingerprint
.last-used-at
last used: .key-list-item-dates.d-flex.align-items-start.justify-content-between
= key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : 'n/a' %span.last-used-at.append-right-10
.float-right = s_('Profiles|Last used:')
%span.key-created-at = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : _('Never')
= s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)} %span.expires.append-right-10
- if key.can_delete? = s_('Profiles|Expires:')
= link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent prepend-left-10" do = key.expires_at ? key.expires_at.to_date : _('Never')
%span.sr-only= _('Remove') %span.key-created-at
= icon('trash') = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)}
- if key.can_delete?
= link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent prepend-left-10 align-baseline" do
%span.sr-only= _('Remove')
= sprite_icon('remove', size: 16)
...@@ -11,9 +11,12 @@ ...@@ -11,9 +11,12 @@
%li %li
%span.light= _('Created on:') %span.light= _('Created on:')
%strong= @key.created_at.to_s(:medium) %strong= @key.created_at.to_s(:medium)
%li
%span.light= _('Expires:')
%strong= @key.expires_at.try(:to_s, :medium) || _('Never')
%li %li
%span.light= _('Last used on:') %span.light= _('Last used on:')
%strong= @key.last_used_at.try(:to_s, :medium) || 'N/A' %strong= @key.last_used_at.try(:to_s, :medium) || _('Never')
.col-md-8 .col-md-8
= form_errors(@key, type: 'key') unless @key.valid? = form_errors(@key, type: 'key') unless @key.valid?
......
- is_admin = local_assigns.fetch(:admin, false) - is_admin = local_assigns.fetch(:admin, false)
- if @keys.any? - if @keys.any?
%ul.content-list{ data: { qa_selector: 'ssh_keys_list' } } %ul.content-list.ssh-keys-list{ data: { qa_selector: 'ssh_keys_list' } }
= render partial: 'profiles/keys/key', collection: @keys, locals: { is_admin: is_admin } = render partial: 'profiles/keys/key', collection: @keys, locals: { is_admin: is_admin }
- else - else
%p.settings-message.text-center %p.settings-message.text-center
......
---
title: Introduce optional expiry date for SSH Keys
merge_request: 26351
author:
type: added
# frozen_string_literal: true
class AddExpiresAtToKeys < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :keys, :expires_at, :datetime_with_timezone
end
end
...@@ -2291,6 +2291,7 @@ ActiveRecord::Schema.define(version: 2020_03_11_165635) do ...@@ -2291,6 +2291,7 @@ ActiveRecord::Schema.define(version: 2020_03_11_165635) do
t.boolean "public", default: false, null: false t.boolean "public", default: false, null: false
t.datetime "last_used_at" t.datetime "last_used_at"
t.binary "fingerprint_sha256" t.binary "fingerprint_sha256"
t.datetime_with_timezone "expires_at"
t.index ["fingerprint"], name: "index_keys_on_fingerprint", unique: true t.index ["fingerprint"], name: "index_keys_on_fingerprint", unique: true
t.index ["fingerprint_sha256"], name: "index_keys_on_fingerprint_sha256" t.index ["fingerprint_sha256"], name: "index_keys_on_fingerprint_sha256"
t.index ["id", "type"], name: "index_on_deploy_keys_id_and_type_and_public", unique: true, where: "(public = true)" t.index ["id", "type"], name: "index_on_deploy_keys_id_and_type_and_public", unique: true, where: "(public = true)"
......
...@@ -24,6 +24,7 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" 'https://gitlab.example.com/a ...@@ -24,6 +24,7 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" 'https://gitlab.example.com/a
"title": "Sample key 25", "title": "Sample key 25",
"key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1256k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=", "key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1256k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=",
"created_at": "2015-09-03T07:24:44.627Z", "created_at": "2015-09-03T07:24:44.627Z",
"expires_at": "2020-05-05T00:00:00.000Z"
"user": { "user": {
"name": "John Smith", "name": "John Smith",
"username": "john_smith", "username": "john_smith",
...@@ -92,6 +93,7 @@ Example response: ...@@ -92,6 +93,7 @@ Example response:
"title": "Sample key 1", "title": "Sample key 1",
"key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1016k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=", "key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1016k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=",
"created_at": "2019-11-14T15:11:13.222Z", "created_at": "2019-11-14T15:11:13.222Z",
"expires_at": "2020-05-05T00:00:00.000Z"
"user": { "user": {
"id": 1, "id": 1,
"name": "Administrator", "name": "Administrator",
......
...@@ -186,8 +186,11 @@ Now, it's time to add the newly created public key to your GitLab account. ...@@ -186,8 +186,11 @@ Now, it's time to add the newly created public key to your GitLab account.
1. Navigating to **SSH Keys** and pasting your **public** key from the clipboard into the **Key** field. If you: 1. Navigating to **SSH Keys** and pasting your **public** key from the clipboard into the **Key** field. If you:
- Created the key with a comment, this will appear in the **Title** field. - Created the key with a comment, this will appear in the **Title** field.
- Created the key without a comment, give your key an identifiable title like _Work Laptop_ or _Home Workstation_. - Created the key without a comment, give your key an identifiable title like _Work Laptop_ or _Home Workstation_.
1. Choose an (optional) expiry date for the key under "Expires at" section. (Introduced in [GitLab 12.9](https://gitlab.com/gitlab-org/gitlab/-/issues/36243))
1. Click the **Add key** button. 1. Click the **Add key** button.
SSH keys that have "expired" using this procedure will still be valid in GitLab workflows. As the GitLab-configured expiration date is not included in the SSH key itself, you can still export public SSH keys as needed.
NOTE: **Note:** NOTE: **Note:**
If you manually copied your public SSH key make sure you copied the entire If you manually copied your public SSH key make sure you copied the entire
key starting with `ssh-ed25519` (or `ssh-rsa`) and ending with your email. key starting with `ssh-ed25519` (or `ssh-rsa`) and ending with your email.
......
...@@ -43,7 +43,7 @@ module EE ...@@ -43,7 +43,7 @@ module EE
end end
override :check_for_console_messages override :check_for_console_messages
def check_for_console_messages(cmd) def check_for_console_messages
super.push( super.push(
*current_replication_lag_message *current_replication_lag_message
) )
......
...@@ -8,7 +8,7 @@ module Gitlab ...@@ -8,7 +8,7 @@ module Gitlab
check_can_create_design! check_can_create_design!
end end
success_result(cmd) success_result
end end
private private
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module API module API
module Entities module Entities
class SSHKey < Grape::Entity class SSHKey < Grape::Entity
expose :id, :title, :key, :created_at expose :id, :title, :key, :created_at, :expires_at
end end
end end
end end
# frozen_string_literal: true
module Gitlab
module Auth
class KeyStatusChecker
include Gitlab::Utils::StrongMemoize
attr_reader :key
def initialize(key)
@key = key
end
def show_console_message?
console_message.present?
end
def console_message
strong_memoize(:console_message) do
if key.expired?
_('INFO: Your SSH key has expired. Please generate a new key.')
elsif key.expires_soon?
_('INFO: Your SSH key is expiring soon. Please generate a new key.')
end
end
end
end
end
end
...@@ -81,7 +81,7 @@ module Gitlab ...@@ -81,7 +81,7 @@ module Gitlab
check_push_access! check_push_access!
end end
success_result(cmd) success_result
end end
def guest_can_download_code? def guest_can_download_code?
...@@ -119,12 +119,24 @@ module Gitlab ...@@ -119,12 +119,24 @@ module Gitlab
nil nil
end end
def check_for_console_messages(cmd) def check_for_console_messages
return console_messages unless key?
key_status = Gitlab::Auth::KeyStatusChecker.new(actor)
if key_status.show_console_message?
console_messages.push(key_status.console_message)
else
console_messages
end
end
def console_messages
[] []
end end
def check_valid_actor! def check_valid_actor!
return unless actor.is_a?(Key) return unless key?
unless actor.valid? unless actor.valid?
raise ForbiddenError, "Your SSH key #{actor.errors[:key].first}." raise ForbiddenError, "Your SSH key #{actor.errors[:key].first}."
...@@ -340,6 +352,10 @@ module Gitlab ...@@ -340,6 +352,10 @@ module Gitlab
actor == :ci actor == :ci
end end
def key?
actor.is_a?(Key)
end
def can_read_project? def can_read_project?
if deploy_key? if deploy_key?
deploy_key.has_access_to?(project) deploy_key.has_access_to?(project)
...@@ -374,8 +390,8 @@ module Gitlab ...@@ -374,8 +390,8 @@ module Gitlab
protected protected
def success_result(cmd) def success_result
::Gitlab::GitAccessResult::Success.new(console_messages: check_for_console_messages(cmd)) ::Gitlab::GitAccessResult::Success.new(console_messages: check_for_console_messages)
end end
def changes_list def changes_list
......
...@@ -8215,6 +8215,9 @@ msgstr "" ...@@ -8215,6 +8215,9 @@ msgstr ""
msgid "Expires in %{expires_at}" msgid "Expires in %{expires_at}"
msgstr "" msgstr ""
msgid "Expires:"
msgstr ""
msgid "Explain the problem. If appropriate, provide a link to the relevant issue or comment." msgid "Explain the problem. If appropriate, provide a link to the relevant issue or comment."
msgstr "" msgstr ""
...@@ -10500,6 +10503,12 @@ msgstr "" ...@@ -10500,6 +10503,12 @@ msgstr ""
msgid "IDE|This option is disabled because you don't have write permissions for the current branch." msgid "IDE|This option is disabled because you don't have write permissions for the current branch."
msgstr "" msgstr ""
msgid "INFO: Your SSH key has expired. Please generate a new key."
msgstr ""
msgid "INFO: Your SSH key is expiring soon. Please generate a new key."
msgstr ""
msgid "IP Address" msgid "IP Address"
msgstr "" msgstr ""
...@@ -12867,9 +12876,6 @@ msgstr "" ...@@ -12867,9 +12876,6 @@ msgstr ""
msgid "Name new label" msgid "Name new label"
msgstr "" msgstr ""
msgid "Name your individual key via a title"
msgstr ""
msgid "Name:" msgid "Name:"
msgstr "" msgstr ""
...@@ -14857,12 +14863,21 @@ msgstr "" ...@@ -14857,12 +14863,21 @@ msgstr ""
msgid "Profiles|Enter your name, so people you know can recognize you" msgid "Profiles|Enter your name, so people you know can recognize you"
msgstr "" msgstr ""
msgid "Profiles|Expires at"
msgstr ""
msgid "Profiles|Expires:"
msgstr ""
msgid "Profiles|Feed token was successfully reset" msgid "Profiles|Feed token was successfully reset"
msgstr "" msgstr ""
msgid "Profiles|Full name" msgid "Profiles|Full name"
msgstr "" msgstr ""
msgid "Profiles|Give your individual key a title"
msgstr ""
msgid "Profiles|Impersonation" msgid "Profiles|Impersonation"
msgstr "" msgstr ""
...@@ -14884,6 +14899,9 @@ msgstr "" ...@@ -14884,6 +14899,9 @@ msgstr ""
msgid "Profiles|Key" msgid "Profiles|Key"
msgstr "" msgstr ""
msgid "Profiles|Last used:"
msgstr ""
msgid "Profiles|Learn more" msgid "Profiles|Learn more"
msgstr "" msgstr ""
...@@ -15040,6 +15058,9 @@ msgstr "" ...@@ -15040,6 +15058,9 @@ msgstr ""
msgid "Profiles|Your email address was automatically set based on your %{provider_label} account" msgid "Profiles|Your email address was automatically set based on your %{provider_label} account"
msgstr "" msgstr ""
msgid "Profiles|Your key has expired"
msgstr ""
msgid "Profiles|Your location was automatically set based on your %{provider_label} account" msgid "Profiles|Your location was automatically set based on your %{provider_label} account"
msgstr "" msgstr ""
......
...@@ -5,6 +5,22 @@ require 'spec_helper' ...@@ -5,6 +5,22 @@ require 'spec_helper'
describe Profiles::KeysController do describe Profiles::KeysController do
let(:user) { create(:user) } let(:user) { create(:user) }
describe 'POST #create' do
before do
sign_in(user)
end
it 'creates a new key' do
expires_at = 3.days.from_now
expect do
post :create, params: { key: build(:key, expires_at: expires_at).attributes }
end.to change { Key.count }.by(1)
expect(Key.last.expires_at).to be_like_time(expires_at)
end
end
describe "#get_keys" do describe "#get_keys" do
describe "non existent user" do describe "non existent user" do
it "does not generally work" do it "does not generally work" do
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Auth::KeyStatusChecker do
let_it_be(:never_expires_key) { build(:personal_key, expires_at: nil) }
let_it_be(:expired_key) { build(:personal_key, expires_at: 3.days.ago) }
let_it_be(:expiring_soon_key) { build(:personal_key, expires_at: 3.days.from_now) }
let_it_be(:expires_in_future_key) { build(:personal_key, expires_at: 14.days.from_now) }
let(:key_status_checker) { described_class.new(key) }
describe '#show_console_message?' do
subject { key_status_checker.show_console_message? }
context 'for an expired key' do
let(:key) { expired_key }
it { is_expected.to eq(true) }
end
context 'for a key expiring in the next 7 days' do
let(:key) { expiring_soon_key }
it { is_expected.to eq(true) }
end
context 'for a key expiring after the next 7 days' do
let(:key) { expires_in_future_key }
it { is_expected.to eq(false) }
end
context 'for a key that never expires' do
let(:key) { never_expires_key }
it { is_expected.to eq(false) }
end
end
describe '#console_message' do
subject { key_status_checker.console_message }
context 'for an expired key' do
let(:key) { expired_key }
it { is_expected.to eq('INFO: Your SSH key has expired. Please generate a new key.') }
end
context 'for a key expiring in the next 7 days' do
let(:key) { expiring_soon_key }
it { is_expected.to eq('INFO: Your SSH key is expiring soon. Please generate a new key.') }
end
context 'for a key expiring after the next 7 days' do
let(:key) { expires_in_future_key }
it { is_expected.to be_nil }
end
context 'for a key that never expires' do
let(:key) { never_expires_key }
it { is_expected.to be_nil }
end
end
end
...@@ -575,30 +575,35 @@ describe API::Internal::Base do ...@@ -575,30 +575,35 @@ describe API::Internal::Base do
project.add_developer(user) project.add_developer(user)
end end
context "git pull" do context 'git pull' do
context "with no console message" do context 'with a key that has expired' do
it "has the correct payload" do let(:key) { create(:key, user: user, expires_at: 2.days.ago) }
it 'includes the `key expired` message in the response' do
pull(key, project) pull(key, project)
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response['gl_console_messages']).to eq([]) expect(json_response['gl_console_messages']).to eq(['INFO: Your SSH key has expired. Please generate a new key.'])
end end
end end
context "with a console message" do context 'with a key that will expire in the next 7 days' do
let(:console_messages) { ['message for the console'] } let(:key) { create(:key, user: user, expires_at: 2.days.from_now) }
it "has the correct payload" do it 'includes the `key expiring soon` message in the response' do
expect_next_instance_of(Gitlab::GitAccess) do |access| pull(key, project)
expect(access).to receive(:check_for_console_messages)
.with('git-upload-pack')
.and_return(console_messages)
end
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['gl_console_messages']).to eq(['INFO: Your SSH key is expiring soon. Please generate a new key.'])
end
end
context 'with a key that has no expiry' do
it 'does not include any message in the response' do
pull(key, project) pull(key, project)
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response['gl_console_messages']).to eq(console_messages) expect(json_response['gl_console_messages']).to eq([])
end end
end end
end end
......
...@@ -5,7 +5,7 @@ require 'spec_helper' ...@@ -5,7 +5,7 @@ require 'spec_helper'
describe API::Keys do describe API::Keys do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:admin) { create(:admin) } let(:admin) { create(:admin) }
let(:key) { create(:key, user: user) } let(:key) { create(:key, user: user, expires_at: 1.day.from_now) }
let(:email) { create(:email, user: user) } let(:email) { create(:email, user: user) }
describe 'GET /keys/:uid' do describe 'GET /keys/:uid' do
...@@ -28,6 +28,7 @@ describe API::Keys do ...@@ -28,6 +28,7 @@ describe API::Keys do
get api("/keys/#{key.id}", admin) get api("/keys/#{key.id}", admin)
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(key.title) expect(json_response['title']).to eq(key.title)
expect(Time.parse(json_response['expires_at'])).to be_like_time(key.expires_at)
expect(json_response['user']['id']).to eq(user.id) expect(json_response['user']['id']).to eq(user.id)
expect(json_response['user']['username']).to eq(user.username) expect(json_response['user']['username']).to eq(user.username)
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment