mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 22:15:14 +00:00
Merge remote-tracking branch 'upstream/main' into OAuth_Delegated_Token
This commit is contained in:
commit
bcdcbca078
8 changed files with 239 additions and 29 deletions
|
|
@ -60,3 +60,6 @@ S3_ACCESS_KEY_ID=your_s3_access_key_id_here
|
|||
S3_SECRET_ACCESS_KEY=your_s3_secret_access_key_here
|
||||
S3_BUCKET=your_s3_bucket_name_here
|
||||
S3_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
|
||||
|
||||
# Key for Revoker (https://github.com/hackclub/revoker)
|
||||
HKA_REVOCATION_KEY=your_hka_revocation_key_here
|
||||
|
|
|
|||
|
|
@ -1,24 +1,60 @@
|
|||
module Api
|
||||
module Internal
|
||||
class RevocationsController < Api::Internal::ApplicationController
|
||||
REGULAR_KEY_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
||||
ADMIN_KEY_REGEX = /\Ahka_[0-9a-f]{64}\z/
|
||||
|
||||
def create
|
||||
token = params[:token]
|
||||
|
||||
return head 400 unless token.present?
|
||||
return render_error("Token is required") unless token.present?
|
||||
|
||||
admin_api_key = AdminApiKey.active.find_by(token:)
|
||||
key, user, token_type, token_format = find_key_info(token)
|
||||
return render_error("Token doesn't match any supported type") unless token_format
|
||||
return render_error("Token is invalid or already revoked") unless key.present?
|
||||
original_key_name = key.name
|
||||
return render_error("Token is invalid or already revoked") unless revoke_key(key)
|
||||
|
||||
return render json: { success: false } unless admin_api_key.present?
|
||||
|
||||
admin_api_key.revoke!
|
||||
|
||||
user = admin_api_key.user
|
||||
|
||||
render json: {
|
||||
response_payload = {
|
||||
success: true,
|
||||
owner_email: user.email_addresses.first&.email,
|
||||
key_name: admin_api_key.name
|
||||
}.compact_blank
|
||||
status: "complete",
|
||||
token_type: token_type,
|
||||
owner_email: user.email_addresses.first&.email
|
||||
}
|
||||
response_payload[:key_name] = original_key_name if token_format == :admin
|
||||
|
||||
render json: response_payload.compact_blank, status: :created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_key_info(token)
|
||||
if token.match?(ADMIN_KEY_REGEX)
|
||||
key = AdminApiKey.active.find_by(token:)
|
||||
return [ key, key&.user, key&.name, :admin ]
|
||||
end
|
||||
|
||||
if token.match?(REGULAR_KEY_REGEX)
|
||||
key = ApiKey.find_by(token:)
|
||||
return [ key, key&.user, key&.name, :regular ]
|
||||
end
|
||||
|
||||
[ nil, nil, nil, nil ]
|
||||
end
|
||||
|
||||
def revoke_key(key)
|
||||
if key.is_a?(AdminApiKey)
|
||||
key.revoke!
|
||||
else
|
||||
key.user.rotate_single_api_key!(key)
|
||||
end
|
||||
rescue ActiveRecord::ActiveRecordError => e
|
||||
report_error(e)
|
||||
false
|
||||
end
|
||||
|
||||
def render_error(message)
|
||||
render json: { success: false, error: message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private def authenticate!
|
||||
|
|
|
|||
|
|
@ -14,14 +14,10 @@ class Settings::AccessController < Settings::BaseController
|
|||
end
|
||||
|
||||
def rotate_api_key
|
||||
@user.api_keys.transaction do
|
||||
@user.api_keys.destroy_all
|
||||
new_api_key = @user.rotate_api_keys!
|
||||
|
||||
new_api_key = @user.api_keys.create!(name: "Hackatime key")
|
||||
|
||||
PosthogService.capture(@user, "api_key_rotated")
|
||||
render json: { token: new_api_key.token }, status: :ok
|
||||
end
|
||||
PosthogService.capture(@user, "api_key_rotated")
|
||||
render json: { token: new_api_key.token }, status: :ok
|
||||
rescue => e
|
||||
report_error(e, message: "error rotate #{e.class.name}")
|
||||
render json: { error: "cant rotate" }, status: :unprocessable_entity
|
||||
|
|
|
|||
|
|
@ -286,6 +286,20 @@ class User < ApplicationRecord
|
|||
sign_in_tokens.create!(auth_type: :email, continue_param: continue_param)
|
||||
end
|
||||
|
||||
def rotate_api_keys!
|
||||
api_keys.transaction do
|
||||
api_keys.destroy_all
|
||||
api_keys.create!(name: "Hackatime key")
|
||||
end
|
||||
end
|
||||
|
||||
def rotate_single_api_key!(api_key)
|
||||
raise ActiveRecord::RecordNotFound unless api_key.user_id == id
|
||||
|
||||
api_key.update!(token: SecureRandom.uuid_v4)
|
||||
api_key
|
||||
end
|
||||
|
||||
def find_valid_token(token)
|
||||
sign_in_tokens.valid.find_by(token: token)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,14 +12,19 @@ RSpec.describe 'Api::Internal', type: :request do
|
|||
parameter name: :payload, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
token: { type: :string }
|
||||
token: { type: :string },
|
||||
submitter: { type: :string },
|
||||
comment: { type: :string }
|
||||
},
|
||||
required: [ 'token' ]
|
||||
}
|
||||
|
||||
response(200, 'successful') do
|
||||
response(201, 'created') do
|
||||
let(:Authorization) { "Bearer test_revocation_key" }
|
||||
let(:payload) { { token: 'some_token' } }
|
||||
let(:user) { User.create!(timezone: "UTC") }
|
||||
let!(:email_address) { user.email_addresses.create!(email: "internal@example.com", source: :signing_in) }
|
||||
let!(:api_key) { user.api_keys.create!(name: "Desktop") }
|
||||
let(:payload) { { token: api_key.token } }
|
||||
|
||||
before do
|
||||
ENV["HKA_REVOCATION_KEY"] = "test_revocation_key"
|
||||
|
|
@ -32,15 +37,25 @@ RSpec.describe 'Api::Internal', type: :request do
|
|||
schema type: :object,
|
||||
properties: {
|
||||
success: { type: :boolean },
|
||||
status: { type: :string },
|
||||
token_type: { type: :string },
|
||||
owner_email: { type: :string, nullable: true },
|
||||
key_name: { type: :string, nullable: true }
|
||||
}
|
||||
run_test!
|
||||
run_test! do |response|
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body["success"]).to eq(true)
|
||||
expect(body["status"]).to eq("complete")
|
||||
expect(body["token_type"]).to eq("Hackatime API Key")
|
||||
expect(body["owner_email"]).to eq(email_address.email)
|
||||
expect(body["key_name"]).to eq(api_key.name)
|
||||
end
|
||||
end
|
||||
|
||||
response(400, 'bad request') do
|
||||
response(422, 'unprocessable entity') do
|
||||
let(:Authorization) { "Bearer test_revocation_key" }
|
||||
let(:payload) { { token: nil } }
|
||||
let(:payload) { { token: SecureRandom.uuid_v4 } }
|
||||
|
||||
before do
|
||||
ENV["HKA_REVOCATION_KEY"] = "test_revocation_key"
|
||||
|
|
@ -50,6 +65,12 @@ RSpec.describe 'Api::Internal', type: :request do
|
|||
ENV.delete("HKA_REVOCATION_KEY")
|
||||
end
|
||||
|
||||
schema type: :object,
|
||||
properties: {
|
||||
success: { type: :boolean },
|
||||
error: { type: :string }
|
||||
},
|
||||
required: [ 'success', 'error' ]
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1905,8 +1905,8 @@ paths:
|
|||
- InternalToken: []
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: successful
|
||||
'201':
|
||||
description: created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
|
@ -1914,14 +1914,30 @@ paths:
|
|||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
status:
|
||||
type: string
|
||||
token_type:
|
||||
type: string
|
||||
owner_email:
|
||||
type: string
|
||||
nullable: true
|
||||
key_name:
|
||||
type: string
|
||||
nullable: true
|
||||
'400':
|
||||
description: bad request
|
||||
'422':
|
||||
description: unprocessable entity
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
error:
|
||||
type: string
|
||||
required:
|
||||
- success
|
||||
- error
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
|
@ -1930,6 +1946,10 @@ paths:
|
|||
properties:
|
||||
token:
|
||||
type: string
|
||||
submitter:
|
||||
type: string
|
||||
comment:
|
||||
type: string
|
||||
required:
|
||||
- token
|
||||
"/api/summary":
|
||||
|
|
|
|||
96
test/controllers/api/internal/revocations_controller_test.rb
Normal file
96
test/controllers/api/internal/revocations_controller_test.rb
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
require "test_helper"
|
||||
|
||||
class Api::Internal::RevocationsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@previous_revocation_key = ENV["HKA_REVOCATION_KEY"]
|
||||
ENV["HKA_REVOCATION_KEY"] = "test-revocation-key"
|
||||
end
|
||||
|
||||
teardown do
|
||||
ENV["HKA_REVOCATION_KEY"] = @previous_revocation_key
|
||||
end
|
||||
|
||||
test "revokes regular ApiKey by rolling token" do
|
||||
user = User.create!(timezone: "UTC")
|
||||
email_address = user.email_addresses.create!(email: "regular@example.com", source: :signing_in)
|
||||
original_token = SecureRandom.uuid_v4
|
||||
key = user.api_keys.create!(name: "Desktop", token: original_token)
|
||||
|
||||
post "/api/internal/revoke", params: { token: original_token }, headers: auth_headers, as: :json
|
||||
|
||||
assert_response :created
|
||||
assert_equal true, response.parsed_body["success"]
|
||||
assert_equal "complete", response.parsed_body["status"]
|
||||
assert_equal key.name, response.parsed_body["token_type"]
|
||||
assert_equal email_address.email, response.parsed_body["owner_email"]
|
||||
assert_not_includes response.parsed_body.keys, "key_name"
|
||||
|
||||
key.reload
|
||||
assert_not_equal original_token, key.token
|
||||
assert_nil ApiKey.find_by(token: original_token)
|
||||
|
||||
post "/api/internal/revoke", params: { token: original_token }, headers: auth_headers, as: :json
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal false, response.parsed_body["success"]
|
||||
assert_equal "Token is invalid or already revoked", response.parsed_body["error"]
|
||||
end
|
||||
|
||||
test "returns success false for valid regular UUID token that does not exist" do
|
||||
token = SecureRandom.uuid_v4
|
||||
|
||||
post "/api/internal/revoke", params: { token: token }, headers: auth_headers, as: :json
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal false, response.parsed_body["success"]
|
||||
assert_equal "Token is invalid or already revoked", response.parsed_body["error"]
|
||||
end
|
||||
|
||||
test "returns success false for token that matches neither regex" do
|
||||
post "/api/internal/revoke", params: { token: "not-a-valid-token" }, headers: auth_headers, as: :json
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal false, response.parsed_body["success"]
|
||||
assert_equal "Token doesn't match any supported type", response.parsed_body["error"]
|
||||
end
|
||||
|
||||
test "revokes admin key" do
|
||||
user = User.create!(timezone: "UTC")
|
||||
email_address = user.email_addresses.create!(email: "admin@example.com", source: :signing_in)
|
||||
admin_key = user.admin_api_keys.create!(name: "Infra", token: "hka_#{SecureRandom.hex(32)}")
|
||||
|
||||
post "/api/internal/revoke", params: { token: admin_key.token }, headers: auth_headers, as: :json
|
||||
|
||||
assert_response :created
|
||||
assert_equal true, response.parsed_body["success"]
|
||||
assert_equal "complete", response.parsed_body["status"]
|
||||
assert_equal "Infra", response.parsed_body["token_type"]
|
||||
|
||||
admin_key.reload
|
||||
assert_equal email_address.email, response.parsed_body["owner_email"]
|
||||
assert_equal "Infra", response.parsed_body["key_name"]
|
||||
assert_not_nil admin_key.revoked_at
|
||||
assert_includes admin_key.name, "_revoked_"
|
||||
end
|
||||
|
||||
test "returns error for already-revoked admin key" do
|
||||
user = User.create!(timezone: "UTC")
|
||||
original_token = "hka_#{SecureRandom.hex(32)}"
|
||||
admin_key = user.admin_api_keys.create!(name: "Infra", token: original_token)
|
||||
admin_key.revoke!
|
||||
|
||||
post "/api/internal/revoke", params: { token: original_token }, headers: auth_headers, as: :json
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal false, response.parsed_body["success"]
|
||||
assert_equal "Token is invalid or already revoked", response.parsed_body["error"]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def auth_headers
|
||||
{
|
||||
"Authorization" => ActionController::HttpAuthentication::Token.encode_credentials(ENV.fetch("HKA_REVOCATION_KEY"))
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -30,6 +30,30 @@ class UserTest < ActiveSupport::TestCase
|
|||
assert_equal "gruvbox_dark", metadata[:value]
|
||||
end
|
||||
|
||||
test "rotate_api_keys! replaces existing api key with a new one" do
|
||||
user = User.create!(timezone: "UTC", slack_uid: "U#{SecureRandom.hex(8)}")
|
||||
user.api_keys.create!(name: "Original key")
|
||||
original_token = user.api_keys.first.token
|
||||
|
||||
new_api_key = user.rotate_api_keys!
|
||||
|
||||
assert_equal user.id, new_api_key.user_id
|
||||
assert_equal "Hackatime key", new_api_key.name
|
||||
assert_nil ApiKey.find_by(token: original_token)
|
||||
end
|
||||
|
||||
test "rotate_api_keys! creates a key when none exists" do
|
||||
user = User.create!(timezone: "UTC", slack_uid: "U#{SecureRandom.hex(8)}")
|
||||
|
||||
assert_equal 0, user.api_keys.count
|
||||
|
||||
new_api_key = user.rotate_api_keys!
|
||||
|
||||
assert_equal user.id, new_api_key.user_id
|
||||
assert_equal "Hackatime key", new_api_key.name
|
||||
assert_equal [ new_api_key.id ], user.api_keys.reload.pluck(:id)
|
||||
end
|
||||
|
||||
test "flipper id uses the user id" do
|
||||
user = User.create!(timezone: "UTC")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue