mirror of
https://github.com/internetee/registry.git
synced 2025-07-31 15:06:23 +02:00
Add UserCertificate model with tests
- Create UserCertificate model with validations and certificate renewal logic - Add tests for UserCertificate model functionality - Add user certificates fixtures for testing - Add association between ApiUser and UserCertificates - Add required gems: dry-types, dry-struct, openssl - Add /certs to .gitignore This commit implements the base model for storing user certificates in the database, including private keys, CSRs, certificates and P12 files. The model includes basic validation and certificate renewal functionality, with comprehensive test coverage.
This commit is contained in:
parent
c192d3bf08
commit
51035d1ddf
11 changed files with 403 additions and 3 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -23,6 +23,4 @@ ettevotja_rekvisiidid__lihtandmed.csv.zip
|
|||
Dockerfile.dev
|
||||
.cursor/
|
||||
.cursorrules
|
||||
|
||||
# Ignore sensitive certificates
|
||||
/certs/
|
||||
|
|
3
Gemfile
3
Gemfile
|
@ -79,6 +79,9 @@ gem 'wkhtmltopdf-binary', '~> 0.12.6.1'
|
|||
gem 'directo', github: 'internetee/directo', branch: 'master'
|
||||
|
||||
gem 'strong_migrations'
|
||||
gem 'dry-types'
|
||||
gem 'dry-struct'
|
||||
gem 'openssl'
|
||||
|
||||
group :development, :test do
|
||||
gem 'pry', '0.15.2'
|
||||
|
|
26
Gemfile.lock
26
Gemfile.lock
|
@ -226,6 +226,27 @@ GEM
|
|||
docile (1.3.5)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dry-core (1.0.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
logger
|
||||
zeitwerk (~> 2.6)
|
||||
dry-inflector (1.1.0)
|
||||
dry-logic (1.5.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0, < 2)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-struct (1.6.0)
|
||||
dry-core (~> 1.0, < 2)
|
||||
dry-types (>= 1.7, < 2)
|
||||
ice_nine (~> 0.11)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.7.2)
|
||||
bigdecimal (~> 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
dry-logic (~> 1.4)
|
||||
zeitwerk (~> 2.6)
|
||||
erubi (1.13.1)
|
||||
execjs (2.7.0)
|
||||
faraday (2.12.2)
|
||||
|
@ -273,6 +294,7 @@ GEM
|
|||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n_data (0.13.0)
|
||||
ice_nine (0.11.2)
|
||||
isikukood (0.1.2)
|
||||
iso8601 (0.13.0)
|
||||
jmespath (1.6.1)
|
||||
|
@ -383,6 +405,7 @@ GEM
|
|||
validate_email
|
||||
validate_url
|
||||
webfinger (~> 1.2)
|
||||
openssl (3.3.0)
|
||||
orm_adapter (0.5.0)
|
||||
paper_trail (14.0.0)
|
||||
activerecord (>= 6.0)
|
||||
|
@ -588,6 +611,8 @@ DEPENDENCIES
|
|||
directo!
|
||||
dnsruby (~> 1.61)
|
||||
domain_name
|
||||
dry-struct
|
||||
dry-types
|
||||
e_invoice!
|
||||
epp!
|
||||
epp-xml (= 1.2.0)!
|
||||
|
@ -611,6 +636,7 @@ DEPENDENCIES
|
|||
nokogiri (~> 1.16.0)
|
||||
omniauth-rails_csrf_protection
|
||||
omniauth-tara!
|
||||
openssl
|
||||
paper_trail (~> 14.0)
|
||||
pdfkit
|
||||
pg (= 1.5.9)
|
||||
|
|
|
@ -20,6 +20,7 @@ class ApiUser < User
|
|||
# TODO: should have max request limit per day?
|
||||
belongs_to :registrar
|
||||
has_many :certificates
|
||||
has_many :user_certificates
|
||||
|
||||
validates :username, :plain_text_password, :registrar, :roles, presence: true
|
||||
validates :plain_text_password, length: { minimum: min_password_length }
|
||||
|
|
54
app/models/user_certificate.rb
Normal file
54
app/models/user_certificate.rb
Normal file
|
@ -0,0 +1,54 @@
|
|||
class UserCertificate < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
validates :user, presence: true
|
||||
validates :private_key, presence: true
|
||||
|
||||
enum status: {
|
||||
pending: 'pending',
|
||||
active: 'active',
|
||||
revoked: 'revoked'
|
||||
}
|
||||
|
||||
def renewable?
|
||||
return false unless certificate.present?
|
||||
return false if revoked?
|
||||
|
||||
expires_at.present? && expires_at < 30.days.from_now
|
||||
end
|
||||
|
||||
def expired?
|
||||
return false unless certificate.present?
|
||||
return false if revoked?
|
||||
|
||||
expires_at.present? && expires_at < Time.current
|
||||
end
|
||||
|
||||
def renew
|
||||
raise "Certificate cannot be renewed" unless renewable?
|
||||
|
||||
generator = Certificates::CertificateGenerator.new(
|
||||
username: user.username,
|
||||
registrar_code: user.registrar_code,
|
||||
registrar_name: user.registrar_name,
|
||||
user_certificate: self
|
||||
)
|
||||
|
||||
generator.renew_certificate
|
||||
end
|
||||
|
||||
def self.generate_certificates_for_api_user(api_user:)
|
||||
cert = UserCertificate.create!(
|
||||
user: api_user,
|
||||
status: 'pending',
|
||||
private_key: ''
|
||||
)
|
||||
|
||||
Certificates::CertificateGenerator.new(
|
||||
username: api_user.username,
|
||||
registrar_code: api_user.registrar_code,
|
||||
registrar_name: api_user.registrar_name,
|
||||
user_certificate: cert
|
||||
).call
|
||||
end
|
||||
end
|
158
app/services/certificates/certificate_generator.rb
Normal file
158
app/services/certificates/certificate_generator.rb
Normal file
|
@ -0,0 +1,158 @@
|
|||
module Certificates
|
||||
class CertificateGenerator < Dry::Struct
|
||||
attribute :username, Types::Strict::String
|
||||
attribute :registrar_code, Types::Coercible::String
|
||||
attribute :registrar_name, Types::Strict::String
|
||||
attribute :user_certificate, Types.Instance(UserCertificate)
|
||||
|
||||
CERTS_PATH = Rails.root.join('certs')
|
||||
CA_PATH = CERTS_PATH.join('ca')
|
||||
|
||||
# User certificate files
|
||||
USER_CSR_NAME = 'user.csr'
|
||||
USER_KEY_NAME = 'user.key'
|
||||
USER_CRT_NAME = 'user.crt'
|
||||
USER_P12_NAME = 'user.p12'
|
||||
|
||||
# CA files
|
||||
CA_CERT_PATH = CA_PATH.join('certs/ca.crt.pem')
|
||||
CA_KEY_PATH = CA_PATH.join('private/ca.key.pem')
|
||||
CA_PASSWORD = '123456'
|
||||
|
||||
def initialize(*)
|
||||
super
|
||||
ensure_directories_exist
|
||||
end
|
||||
|
||||
def call
|
||||
csr, key = generate_csr_and_key
|
||||
certificate = sign_certificate(csr)
|
||||
create_p12(key, certificate)
|
||||
end
|
||||
|
||||
def generate_csr_and_key
|
||||
key = OpenSSL::PKey::RSA.new(4096)
|
||||
encrypted_key = key.export(OpenSSL::Cipher.new('AES-256-CBC'), CA_PASSWORD)
|
||||
save_private_key(encrypted_key)
|
||||
|
||||
request = OpenSSL::X509::Request.new
|
||||
request.version = 0
|
||||
request.subject = OpenSSL::X509::Name.new([
|
||||
['CN', username, OpenSSL::ASN1::UTF8STRING],
|
||||
['OU', registrar_code, OpenSSL::ASN1::UTF8STRING],
|
||||
['O', registrar_name, OpenSSL::ASN1::UTF8STRING]
|
||||
])
|
||||
|
||||
request.public_key = key.public_key
|
||||
request.sign(key, OpenSSL::Digest::SHA256.new)
|
||||
|
||||
save_csr(request)
|
||||
|
||||
user_certificate&.update!(
|
||||
private_key: encrypted_key,
|
||||
csr: request.to_pem,
|
||||
status: 'pending'
|
||||
)
|
||||
|
||||
[request, key]
|
||||
end
|
||||
|
||||
def sign_certificate(csr)
|
||||
ca_key = OpenSSL::PKey::RSA.new(File.read(CA_KEY_PATH), CA_PASSWORD)
|
||||
ca_cert = OpenSSL::X509::Certificate.new(File.read(CA_CERT_PATH))
|
||||
|
||||
cert = OpenSSL::X509::Certificate.new
|
||||
cert.serial = 0
|
||||
cert.version = 2
|
||||
cert.not_before = Time.now
|
||||
cert.not_after = Time.now + 365 * 24 * 60 * 60 # 1 year
|
||||
|
||||
cert.subject = csr.subject
|
||||
cert.public_key = csr.public_key
|
||||
cert.issuer = ca_cert.subject
|
||||
|
||||
extension_factory = OpenSSL::X509::ExtensionFactory.new
|
||||
extension_factory.subject_certificate = cert
|
||||
extension_factory.issuer_certificate = ca_cert
|
||||
|
||||
cert.add_extension(extension_factory.create_extension("basicConstraints", "CA:FALSE"))
|
||||
cert.add_extension(extension_factory.create_extension("keyUsage", "nonRepudiation,digitalSignature,keyEncipherment"))
|
||||
cert.add_extension(extension_factory.create_extension("subjectKeyIdentifier", "hash"))
|
||||
|
||||
cert.sign(ca_key, OpenSSL::Digest::SHA256.new)
|
||||
|
||||
save_certificate(cert)
|
||||
|
||||
user_certificate&.update!(
|
||||
certificate: cert.to_pem,
|
||||
status: 'active',
|
||||
expires_at: cert.not_after
|
||||
)
|
||||
|
||||
cert
|
||||
end
|
||||
|
||||
def create_p12(key, certificate, password = nil)
|
||||
ca_cert = OpenSSL::X509::Certificate.new(File.read(CA_CERT_PATH))
|
||||
|
||||
p12 = OpenSSL::PKCS12.create(
|
||||
password, # P12 password (optional)
|
||||
username, # Friendly name
|
||||
key, # User's private key
|
||||
certificate, # User's certificate
|
||||
[ca_cert], # Chain of certificates
|
||||
)
|
||||
|
||||
File.open(CERTS_PATH.join(USER_P12_NAME), 'wb') do |file|
|
||||
file.write(p12.to_der)
|
||||
end
|
||||
|
||||
user_certificate&.update!(
|
||||
p12: p12.to_der,
|
||||
p12_password_digest: password ? BCrypt::Password.create(password) : nil
|
||||
)
|
||||
|
||||
p12
|
||||
end
|
||||
|
||||
def renew_certificate
|
||||
raise "Certificate not found" unless user_certificate&.certificate.present?
|
||||
raise "Cannot renew revoked certificate" if user_certificate.revoked?
|
||||
|
||||
# Используем существующий CSR
|
||||
csr = OpenSSL::X509::Request.new(user_certificate.csr)
|
||||
|
||||
# Создаем новый сертификат
|
||||
certificate = sign_certificate(csr)
|
||||
|
||||
# Создаем новый P12 с существующим ключом
|
||||
key = OpenSSL::PKey::RSA.new(user_certificate.private_key, CA_PASSWORD)
|
||||
create_p12(key, certificate)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_directories_exist
|
||||
FileUtils.mkdir_p(CERTS_PATH)
|
||||
FileUtils.mkdir_p(CA_PATH.join('certs'))
|
||||
FileUtils.mkdir_p(CA_PATH.join('private'))
|
||||
|
||||
# Set proper permissions for private directory
|
||||
FileUtils.chmod(0700, CA_PATH.join('private'))
|
||||
end
|
||||
|
||||
def save_private_key(encrypted_key)
|
||||
path = CERTS_PATH.join(USER_KEY_NAME)
|
||||
File.write(path, encrypted_key)
|
||||
FileUtils.chmod(0600, path)
|
||||
end
|
||||
|
||||
def save_csr(request)
|
||||
File.write(CERTS_PATH.join(USER_CSR_NAME), request.to_pem)
|
||||
end
|
||||
|
||||
def save_certificate(certificate)
|
||||
File.write(CERTS_PATH.join(USER_CRT_NAME), certificate.to_pem)
|
||||
end
|
||||
end
|
||||
end
|
5
config/initializers/dry_types.rb
Normal file
5
config/initializers/dry_types.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
include Dry.Types()
|
||||
end
|
19
db/migrate/20250218115707_create_user_certificates.rb
Normal file
19
db/migrate/20250218115707_create_user_certificates.rb
Normal file
|
@ -0,0 +1,19 @@
|
|||
class CreateUserCertificates < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
create_table :user_certificates do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.binary :private_key, null: false
|
||||
t.text :csr
|
||||
t.text :certificate
|
||||
t.binary :p12
|
||||
t.string :status
|
||||
t.datetime :expires_at
|
||||
t.datetime :revoked_at
|
||||
t.string :p12_password_digest
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :user_certificates, [:user_id, :status]
|
||||
end
|
||||
end
|
|
@ -2812,6 +2812,45 @@ CREATE SEQUENCE public.settings_id_seq
|
|||
ALTER SEQUENCE public.settings_id_seq OWNED BY public.settings.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_certificates; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.user_certificates (
|
||||
id bigint NOT NULL,
|
||||
user_id bigint NOT NULL,
|
||||
private_key bytea NOT NULL,
|
||||
csr text,
|
||||
certificate text,
|
||||
p12 bytea,
|
||||
status character varying,
|
||||
expires_at timestamp without time zone,
|
||||
revoked_at timestamp without time zone,
|
||||
p12_password_digest character varying,
|
||||
created_at timestamp(6) without time zone NOT NULL,
|
||||
updated_at timestamp(6) without time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_certificates_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.user_certificates_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_certificates_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.user_certificates_id_seq OWNED BY public.user_certificates.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: users; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -3512,6 +3551,13 @@ ALTER TABLE ONLY public.setting_entries ALTER COLUMN id SET DEFAULT nextval('pub
|
|||
ALTER TABLE ONLY public.settings ALTER COLUMN id SET DEFAULT nextval('public.settings_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_certificates id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.user_certificates ALTER COLUMN id SET DEFAULT nextval('public.user_certificates_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -4210,6 +4256,14 @@ ALTER TABLE ONLY public.zones
|
|||
ADD CONSTRAINT unique_zone_origin UNIQUE (origin);
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_certificates user_certificates_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.user_certificates
|
||||
ADD CONSTRAINT user_certificates_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -4895,6 +4949,20 @@ CREATE UNIQUE INDEX index_setting_entries_on_code ON public.setting_entries USIN
|
|||
CREATE UNIQUE INDEX index_settings_on_thing_type_and_thing_id_and_var ON public.settings USING btree (thing_type, thing_id, var);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_user_certificates_on_user_id; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX index_user_certificates_on_user_id ON public.user_certificates USING btree (user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_user_certificates_on_user_id_and_status; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX index_user_certificates_on_user_id_and_status ON public.user_certificates USING btree (user_id, status);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_users_on_identity_code; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -5040,6 +5108,14 @@ ALTER TABLE ONLY public.domains
|
|||
ADD CONSTRAINT domains_registrar_id_fk FOREIGN KEY (registrar_id) REFERENCES public.registrars(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_certificates fk_rails_03b0a0c9d8; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.user_certificates
|
||||
ADD CONSTRAINT fk_rails_03b0a0c9d8 FOREIGN KEY (user_id) REFERENCES public.users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: invoices fk_rails_242b91538b; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -5718,6 +5794,7 @@ INSERT INTO "schema_migrations" (version) VALUES
|
|||
('20241112124405'),
|
||||
('20241129095711'),
|
||||
('20241206085817'),
|
||||
('20250204094550');
|
||||
('20250204094550'),
|
||||
('20250218115707');
|
||||
|
||||
|
||||
|
|
7
test/fixtures/user_certificates.yml
vendored
Normal file
7
test/fixtures/user_certificates.yml
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
one:
|
||||
user: api_bestnames
|
||||
private_key: "encrypted_private_key_data"
|
||||
csr: "dummy_csr_data"
|
||||
certificate: "dummy_certificate_data"
|
||||
status: "active"
|
||||
expires_at: <%= 60.days.from_now %>
|
52
test/models/user_certificate_test.rb
Normal file
52
test/models/user_certificate_test.rb
Normal file
|
@ -0,0 +1,52 @@
|
|||
require 'test_helper'
|
||||
|
||||
class UserCertificateTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@user = users(:api_bestnames)
|
||||
@certificate = user_certificates(:one)
|
||||
end
|
||||
|
||||
test "should be valid with required attributes" do
|
||||
certificate = UserCertificate.new(
|
||||
user: @user,
|
||||
private_key: 'dummy_key',
|
||||
status: 'pending'
|
||||
)
|
||||
assert certificate.valid?
|
||||
end
|
||||
|
||||
test "should not be valid without user" do
|
||||
@certificate.user = nil
|
||||
assert_not @certificate.valid?
|
||||
end
|
||||
|
||||
test "should not be valid without private_key" do
|
||||
@certificate.private_key = nil
|
||||
assert_not @certificate.valid?
|
||||
end
|
||||
|
||||
test "renewable? should be false without certificate" do
|
||||
@certificate.certificate = nil
|
||||
assert_not @certificate.renewable?
|
||||
end
|
||||
|
||||
test "renewable? should be false when revoked" do
|
||||
@certificate.status = 'revoked'
|
||||
assert_not @certificate.renewable?
|
||||
end
|
||||
|
||||
test "renewable? should be true when expires in less than 30 days" do
|
||||
@certificate.expires_at = 29.days.from_now
|
||||
assert @certificate.renewable?
|
||||
end
|
||||
|
||||
test "expired? should be true when certificate is expired" do
|
||||
@certificate.expires_at = 1.day.ago
|
||||
assert @certificate.expired?
|
||||
end
|
||||
|
||||
test "renew should raise error when certificate is not renewable" do
|
||||
@certificate.status = 'revoked'
|
||||
assert_raises(RuntimeError) { @certificate.renew }
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue