Compare commits
5 commits
d8bdb46a5c
...
2d755b8e3d
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d755b8e3d | |||
| d89409f973 | |||
| f2948a7b64 | |||
| 143e9a9fa9 | |||
| c94e07fbac |
6 changed files with 61 additions and 73 deletions
|
|
@ -56,6 +56,9 @@ class Device(DeviceRead, table=True):
|
||||||
# might be a future problem?
|
# might be a future problem?
|
||||||
owner: User = Relationship(
|
owner: User = Relationship(
|
||||||
back_populates="devices",
|
back_populates="devices",
|
||||||
|
sa_relationship_kwargs={
|
||||||
|
"lazy": "joined",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -282,22 +282,24 @@ class User(UserBase, table=True):
|
||||||
# deny be default
|
# deny be default
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def can_issue(self, device: Device) -> bool:
|
@property
|
||||||
|
def can_issue(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if this user can issue a certificate without approval.
|
Check if this user can issue a certificate without approval.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
device.approved in (None, False)
|
self.is_admin
|
||||||
and (self.is_admin or self.has_tag(TagValue.issue))
|
or self.has_tag(TagValue.issue)
|
||||||
)
|
)
|
||||||
|
|
||||||
def can_renew(self, device: Device) -> bool:
|
@property
|
||||||
|
def can_renew(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if this user can renew a certificate without approval.
|
Check if this user can renew a certificate without approval.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
device.approved is True
|
self.is_admin
|
||||||
and (self.is_admin or self.has_tag(TagValue.renew))
|
or self.has_tag(TagValue.renew)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ Python interface to EasyRSA CA.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from OpenSSL import crypto
|
from cryptography import x509
|
||||||
from passlib import pwd
|
from passlib import pwd
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
@ -140,6 +140,20 @@ class EasyRSA:
|
||||||
None: {},
|
None: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@functools.lru_cache
|
||||||
|
def _load(cls) -> EasyRSA:
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@property
|
||||||
|
def _(cls) -> EasyRSA:
|
||||||
|
"""
|
||||||
|
Get the singleton
|
||||||
|
"""
|
||||||
|
|
||||||
|
return cls._load()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def output_directory(self) -> Path:
|
def output_directory(self) -> Path:
|
||||||
"""
|
"""
|
||||||
|
|
@ -196,7 +210,7 @@ class EasyRSA:
|
||||||
cert_filename: Path,
|
cert_filename: Path,
|
||||||
*easyrsa_cmd: str,
|
*easyrsa_cmd: str,
|
||||||
**easyrsa_env: str,
|
**easyrsa_env: str,
|
||||||
) -> crypto.X509:
|
) -> x509.Certificate:
|
||||||
"""
|
"""
|
||||||
Create an X.509 certificate
|
Create an X.509 certificate
|
||||||
"""
|
"""
|
||||||
|
|
@ -231,8 +245,8 @@ class EasyRSA:
|
||||||
with open(
|
with open(
|
||||||
self.output_directory.joinpath(cert_filename), "rb"
|
self.output_directory.joinpath(cert_filename), "rb"
|
||||||
) as cert_file:
|
) as cert_file:
|
||||||
return crypto.load_certificate(
|
return x509.load_pem_x509_certificate(
|
||||||
crypto.FILETYPE_PEM, cert_file.read()
|
cert_file.read()
|
||||||
)
|
)
|
||||||
|
|
||||||
def init_pki(self) -> None:
|
def init_pki(self) -> None:
|
||||||
|
|
@ -242,7 +256,7 @@ class EasyRSA:
|
||||||
|
|
||||||
self.__easyrsa("init-pki")
|
self.__easyrsa("init-pki")
|
||||||
|
|
||||||
def build_ca(self) -> crypto.X509:
|
def build_ca(self) -> x509.Certificate:
|
||||||
"""
|
"""
|
||||||
Build the CA certificate
|
Build the CA certificate
|
||||||
"""
|
"""
|
||||||
|
|
@ -263,7 +277,7 @@ class EasyRSA:
|
||||||
self,
|
self,
|
||||||
cert_type: CertificateType = CertificateType.client,
|
cert_type: CertificateType = CertificateType.client,
|
||||||
dn: DistinguishedName | None = None,
|
dn: DistinguishedName | None = None,
|
||||||
) -> crypto.X509 | None:
|
) -> x509.Certificate | None:
|
||||||
"""
|
"""
|
||||||
Issue a client or server certificate
|
Issue a client or server certificate
|
||||||
"""
|
"""
|
||||||
|
|
@ -302,18 +316,12 @@ if __name__ == "__main__":
|
||||||
Connection.connect(current_config.db.uri)
|
Connection.connect(current_config.db.uri)
|
||||||
|
|
||||||
if (device := Device.get(1)) is not None:
|
if (device := Device.get(1)) is not None:
|
||||||
with Connection.session as db:
|
client = easy_rsa.issue(
|
||||||
db.add(device)
|
dn=DistinguishedName.build(device)
|
||||||
dn = DistinguishedName.build(device)
|
)
|
||||||
|
|
||||||
client = easy_rsa.issue(dn=dn)
|
|
||||||
|
|
||||||
date_format, encoding = "%Y%m%d%H%M%SZ", "ascii"
|
|
||||||
|
|
||||||
for cert in (ca, server, client):
|
for cert in (ca, server, client):
|
||||||
if cert is not None:
|
if cert is not None:
|
||||||
print(cert.get_subject().CN)
|
print(cert.subject)
|
||||||
print(cert.get_signature_algorithm().decode(encoding))
|
print(cert.signature_hash_algorithm)
|
||||||
|
print(cert.not_valid_after)
|
||||||
assert (na := cert.get_notAfter()) is not None
|
|
||||||
print(datetime.strptime(na.decode(encoding), date_format))
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,10 @@
|
||||||
/device endpoints.
|
/device endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from ..db import Connection, Device, DeviceCreate, DeviceRead, User
|
from ..db import Device, DeviceCreate, DeviceRead, User
|
||||||
from ..easyrsa import CertificateType, DistinguishedName, EasyRSA
|
from ..easyrsa import DistinguishedName, EasyRSA
|
||||||
from ._common import (Responses, get_current_user, get_device_by_id,
|
from ._common import (Responses, get_current_user, get_device_by_id,
|
||||||
get_user_by_name)
|
get_user_by_name)
|
||||||
|
|
||||||
|
|
@ -81,47 +79,43 @@ async def remove_device(
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{device_id}/csr",
|
"/{device_id}/issue",
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: Responses.OK,
|
status.HTTP_200_OK: Responses.OK,
|
||||||
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
|
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
|
||||||
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
|
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
|
||||||
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
|
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
|
||||||
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
|
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
|
||||||
|
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
|
||||||
},
|
},
|
||||||
|
response_model=DeviceRead,
|
||||||
)
|
)
|
||||||
async def request_certificate(
|
async def request_certificate(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
device: Device = Depends(get_device_by_id),
|
device: Device = Depends(get_device_by_id),
|
||||||
):
|
) -> Device:
|
||||||
"""
|
"""
|
||||||
POST ./{device_id}/csr: Request certificate for a device.
|
POST ./{device_id}/issue: Request certificate for a device.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# check permission
|
# check permission
|
||||||
if not current_user.can_edit(device):
|
if not current_user.can_edit(device):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
easy_rsa = EasyRSA()
|
# cannot request for a newly created device
|
||||||
|
if device.approved is not None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||||
|
|
||||||
with Connection.session as db:
|
# check if we must wait for approval
|
||||||
db.add(device)
|
device.approved = current_user.can_issue
|
||||||
dn = DistinguishedName.build(device)
|
|
||||||
|
|
||||||
if current_user.can_issue(device):
|
if device.approved:
|
||||||
device.approved = True
|
# issue the certificate immediately
|
||||||
|
if (certificate := EasyRSA._.issue(
|
||||||
|
dn=DistinguishedName.build(device)
|
||||||
|
)) is not None:
|
||||||
|
device.expiry = certificate.not_valid_after
|
||||||
|
|
||||||
if (cert := easy_rsa.issue(
|
# return updated device
|
||||||
dn=dn,
|
device.update()
|
||||||
cert_type=CertificateType.server,
|
return device
|
||||||
)) is not None:
|
|
||||||
assert (expiry := cert.get_notAfter()) is not None
|
|
||||||
|
|
||||||
date_format, encoding = "%Y%m%d%H%M%SZ", "ascii"
|
|
||||||
expiry = datetime.strptime(
|
|
||||||
expiry.decode(encoding),
|
|
||||||
date_format,
|
|
||||||
)
|
|
||||||
device.expiry = expiry
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
|
||||||
21
api/poetry.lock
generated
21
api/poetry.lock
generated
|
|
@ -292,21 +292,6 @@ typing-extensions = ">=3.7.4.3"
|
||||||
dotenv = ["python-dotenv (>=0.10.4)"]
|
dotenv = ["python-dotenv (>=0.10.4)"]
|
||||||
email = ["email-validator (>=1.0.3)"]
|
email = ["email-validator (>=1.0.3)"]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyopenssl"
|
|
||||||
version = "22.0.0"
|
|
||||||
description = "Python wrapper module around the OpenSSL library"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
cryptography = ">=35.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["sphinx", "sphinx-rtd-theme"]
|
|
||||||
test = ["flaky", "pretend", "pytest (>=3.0.1)"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyparsing"
|
name = "pyparsing"
|
||||||
version = "3.0.7"
|
version = "3.0.7"
|
||||||
|
|
@ -501,7 +486,7 @@ standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchgod (>=0.6)", "p
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.10"
|
python-versions = "^3.10"
|
||||||
content-hash = "ec07664a3624e6204beb2371bccc164ca1029f6e80663a9bd5946f4eaea04ca1"
|
content-hash = "36a56b6982734607590597302276605f8977119869934f35116e72377905b6b5"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
anyio = [
|
anyio = [
|
||||||
|
|
@ -790,10 +775,6 @@ pydantic = [
|
||||||
{file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"},
|
{file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"},
|
||||||
{file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"},
|
{file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"},
|
||||||
]
|
]
|
||||||
pyopenssl = [
|
|
||||||
{file = "pyOpenSSL-22.0.0-py2.py3-none-any.whl", hash = "sha256:ea252b38c87425b64116f808355e8da644ef9b07e429398bfece610f893ee2e0"},
|
|
||||||
{file = "pyOpenSSL-22.0.0.tar.gz", hash = "sha256:660b1b1425aac4a1bea1d94168a85d99f0b3144c869dd4390d27629d0087f1bf"},
|
|
||||||
]
|
|
||||||
pyparsing = [
|
pyparsing = [
|
||||||
{file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
|
{file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
|
||||||
{file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
|
{file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ python = "^3.10"
|
||||||
|
|
||||||
fastapi = "^0.75.0"
|
fastapi = "^0.75.0"
|
||||||
passlib = {extras = ["argon2", "bcrypt"], version = "^1.7.4"}
|
passlib = {extras = ["argon2", "bcrypt"], version = "^1.7.4"}
|
||||||
pyOpenSSL = "^22.0.0"
|
|
||||||
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
|
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
|
||||||
python-multipart = "^0.0.5"
|
python-multipart = "^0.0.5"
|
||||||
sqlmodel = "^0.0.6"
|
sqlmodel = "^0.0.6"
|
||||||
uvicorn = "^0.17.6"
|
uvicorn = "^0.17.6"
|
||||||
|
cryptography = "^36.0.2"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^7.1.0"
|
pytest = "^7.1.0"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue