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:
oleghasjanov 2025-02-18 14:37:44 +02:00
parent c192d3bf08
commit 51035d1ddf
11 changed files with 403 additions and 3 deletions

2
.gitignore vendored
View file

@ -23,6 +23,4 @@ ettevotja_rekvisiidid__lihtandmed.csv.zip
Dockerfile.dev
.cursor/
.cursorrules
# Ignore sensitive certificates
/certs/

View file

@ -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'

View file

@ -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)

View file

@ -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 }

View 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

View 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

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
module Types
include Dry.Types()
end

View 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

View file

@ -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
View 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 %>

View 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