WIP: Enable OpenAPI spec generation and integrate with frontend #21

Draft
Mark.TwoFive wants to merge 19 commits from feat/openApiIntegration into main
29 changed files with 2150 additions and 91 deletions

3
.gitignore vendored
View file

@ -40,3 +40,6 @@ out/
### Test files ### ### Test files ###
sqLiteDb.db sqLiteDb.db
dev/ dev/
### Development settings ###
application.properties

View file

@ -1,8 +1,8 @@
plugins { plugins {
java java
id("org.springframework.boot") version "3.3.5" id("org.springframework.boot") version "3.5.0"
id("io.spring.dependency-management") version "1.1.6" id("io.spring.dependency-management") version "1.1.7"
id("com.diffplug.spotless") version "6.25.0" id("com.diffplug.spotless") version "7.0.4"
} }
group = "io.github.markl4yg.hca" group = "io.github.markl4yg.hca"
@ -42,6 +42,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-core")

3
frontend/.gitignore vendored
View file

@ -20,3 +20,6 @@ pnpm-debug.log*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Generated source files
src/generated/

View file

@ -8,15 +8,19 @@
"build": "run-p type-check \"build-only {@}\" --", "build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"update-types": "openapi-typescript http://localhost:8080/v3/api-docs.yaml -o src/generated/api-spec.ts",
"type-check": "vue-tsc --build --force", "type-check": "vue-tsc --build --force",
"lint": "eslint . --fix" "lint": "eslint . --fix"
}, },
"dependencies": { "dependencies": {
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
"@tanstack/vue-query": "^5.83.0",
"core-js": "^3.41.0", "core-js": "^3.41.0",
"iso-3166-1": "^2.1.1",
"openapi-fetch": "^0.14.0",
"roboto-fontface": "*", "roboto-fontface": "*",
"vue": "^3.5.13", "vue": "^3.5.13",
"vuetify": "^3.8.1" "vuetify": "^3.9.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.24.0", "@eslint/js": "^9.24.0",
@ -31,6 +35,7 @@
"eslint-plugin-vue": "^10.0.0", "eslint-plugin-vue": "^10.0.0",
"msw": "^2.7.4", "msw": "^2.7.4",
"npm-run-all2": "^7.0.2", "npm-run-all2": "^7.0.2",
"openapi-typescript": "^7.8.0",
"sass": "1.86.3", "sass": "1.86.3",
"sass-embedded": "^1.86.3", "sass-embedded": "^1.86.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
@ -38,6 +43,7 @@
"unplugin-vue-components": "^28.5.0", "unplugin-vue-components": "^28.5.0",
"unplugin-vue-router": "^0.12.0", "unplugin-vue-router": "^0.12.0",
"vite": "^6.2.6", "vite": "^6.2.6",
"vite-plugin-vue-devtools": "^7.7.7",
"vite-plugin-vuetify": "^2.1.1", "vite-plugin-vuetify": "^2.1.1",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
"vue-tsc": "^2.2.8" "vue-tsc": "^2.2.8"

1209
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,10 @@
<template> <template>
<v-app> <v-app>
<v-app-bar> <v-app-bar>
<v-app-bar-nav-icon icon="mdi-home" to="/"/> <v-app-bar-nav-icon
icon="mdi-home"
to="/"
/>
</v-app-bar> </v-app-bar>
<v-main> <v-main>
<router-view /> <router-view />

View file

@ -0,0 +1,276 @@
<script setup lang="ts">
import { ref, useId, useTemplateRef } from "vue";
import CountryListItem from "./CountryListItem.vue";
import { type Country, getAvailableCountries, userCountry } from "../utils/countries.ts";
import { type Schemas } from "../plugins/client.ts";
const { isSubmitting } = defineProps({
isSubmitting: Boolean,
})
const formModel = ref(false);
const formRef = useTemplateRef("form");
// Domain fields
const domainInputId = useId();
const domainValue = ref("");
const domains = ref<string[]>([]);
// Subject fields
const commonName = ref("");
const emailAddress = ref("");
const organization = ref("");
const organizationalUnit = ref("");
const country = ref(userCountry?.alpha2);
const countryName = ref("");
const state = ref("");
const locality = ref("");
// Countries data for autocomplete
const countries = ref(getAvailableCountries());
// Certificate settings
const keyLength = ref(4096);
const validityDays = ref(365);
const noExpiry = ref(false);
// Validation rules
const domainRules = [
(value: string) => !!value || "A value must be provided to add the domain.",
(value: string) => /^[a-z0-9][a-z0-9.\-_]+$|^xn--[a-z0-9.\-_]+$/i.test(value) || "Invalid domain characters provided. (To use some special characters, convert the domain to xn-domain format.)"
];
const emailRules = [
(value: string) => !value || /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value) || "Invalid email format."
];
const keyLengthRules = [
(value: number) => value >= 3000 || "Key length must be at least 3000 bits.",
(value: number) => value <= 6000 || "Key length must not exceed 6000 bits."
];
const validityDaysRules = [
(value: number) => !value || value > 0 || "Validity period (in days) must be a positive integer."
];
function addDomain() {
if (formRef.value?.errors.some(({ id }) => domainInputId === id)) {
return;
}
domains.value.push(domainValue.value);
domainValue.value = "";
// Set common name to primary domain if not already set
if (!commonName.value) {
commonName.value = domainValue.value;
}
}
const emit = defineEmits(["submit"]);
async function submitNewCertificate() {
if (!formRef.value?.validate()) {
return;
}
// Extract country code if country is an object
const countryCode = typeof country.value === "object" && country.value !== null
? (country.value as Country).alpha2
: country.value;
// Prepare certificate request data
const certificateRequest: Schemas["CertificateInfo"] = {
type: "STANDALONE_CERTIFICATE",
requestedKeyLength: keyLength.value,
requestedValidityDays: noExpiry.value ? undefined : validityDays.value,
subject: {
commonName: commonName.value,
emailAddress: emailAddress.value,
organization: organization.value,
organizationalUnit: organizationalUnit.value,
country: countryCode,
state: state.value,
locality: locality.value
},
extension: {
alternativeDnsNames: domains.value
}
};
emit("submit", {
certificateRequest
});
}
</script>
<template>
<v-form
ref="form"
v-model="formModel"
:disabled="isSubmitting"
@submit.prevent="submitNewCertificate"
>
<h2>New certificate</h2>
<fieldset class="mb-4">
<legend>Subject details</legend>
<v-text-field
v-model="emailAddress"
label="Email Address"
:rules="emailRules"
hint="Optional"
class="mb-2"
/>
<v-text-field
v-model="organization"
label="Organization"
hint="Optional"
class="mb-2"
/>
<v-text-field
v-model="organizationalUnit"
label="Organizational Unit"
hint="Optional"
class="mb-2"
/>
<v-autocomplete
v-model="country"
v-model:search="countryName"
:items="countries"
item-title="country"
item-value="alpha2"
label="Country"
hint="Search and select your country"
class="mb-2"
>
<template #item="{ props, item }">
<CountryListItem
:item="item"
:item-props="props"
/>
</template>
</v-autocomplete>
<v-text-field
v-model="state"
label="State/Province"
hint="Optional"
class="mb-2"
/>
<v-text-field
v-model="locality"
label="Locality/City"
hint="Optional"
class="mb-2"
/>
</fieldset>
<fieldset class="mb-4">
<legend>Domain Names</legend>
<v-text-field
:id="domainInputId"
v-model="domainValue"
label="Domain(s)"
:rules="domainRules"
@keydown.enter.prevent="addDomain"
hint="Press Enter to add domain"
append-icon="mdi-plus"
@click:append="addDomain"
/>
<ul v-if="domains.length" class="domain-list">
<li
v-for="domain in domains"
:key="domain"
>
<span>{{ domain }}</span>
<v-tooltip
v-if="commonName === domain"
text="This is the primary domain for this certificate. It will be used for the certificate common name."
>
<template #activator="{ props }">
<v-icon
v-bind="props"
icon="mdi-star"
color="amber"
class="ml-2"
/>
</template>
</v-tooltip>
</li>
</ul>
</fieldset>
<fieldset class="mb-4">
<legend>Certificate Settings</legend>
<v-text-field
v-model.number="keyLength"
type="number"
:rules="keyLengthRules"
label="Key Length (bits)"
hint="Value between 3000 and 6000 bits"
class="mb-2"
/>
<v-text-field
v-model.number="validityDays"
type="number"
:rules="validityDaysRules"
label="Validity Period (days)"
hint="Any positive integer value"
:disabled="noExpiry"
/>
<div class="d-flex align-center">
<v-checkbox
v-model="noExpiry"
label="Certificate without expiry"
class="mr-4"
/>
</div>
</fieldset>
<v-btn
class="w-100 w-md-auto ml-md-auto"
type="submit"
color="primary"
:loading="isSubmitting"
>
Send certificate request
</v-btn>
</v-form>
</template>
<style scoped>
fieldset {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 16px;
margin-bottom: 16px;
}
legend {
padding: 0 8px;
font-weight: 500;
}
.domain-list {
list-style-type: none;
padding-left: 0;
margin-top: 8px;
}
.domain-list li {
padding: 8px;
margin-bottom: 4px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 4px;
display: flex;
align-items: center;
}
</style>

View file

@ -0,0 +1,44 @@
<script setup lang="ts">
// Define props with proper TypeScript typing
import { computed } from "vue";
import type { CountryListItemType } from "@/utils/countries.ts";
type ListItem<T> = {
raw: T;
}
// Define props with proper TypeScript typing
const props = defineProps<{
item: ListItem<CountryListItemType>;
itemProps?: Record<string, unknown>;
}>();
// Helper functions to check the type of the item
const isDivider = computed(() => {
return "divider" in props.item.raw && props.item.raw.divider;
});
const headerText = computed(() => {
return "header" in props.item.raw ? props.item.raw.header : "";
});
const countryItem = computed(() => {
return !isDivider.value && !headerText.value && "country" in props.item.raw ? props.item.raw : undefined;
});
</script>
<template>
<v-divider
v-if="isDivider"
class="my-2"
/>
<v-list-subheader v-if="headerText">
{{ headerText }}
</v-list-subheader>
<v-list-item
v-if="countryItem"
v-bind="itemProps"
:title="countryItem.country"
/>
</template>

View file

@ -5,8 +5,12 @@
max-width="900" max-width="900"
> >
<div class="text-center"> <div class="text-center">
<div class="text-body-2 font-weight-light mb-n1">Welcome to</div> <div class="text-body-2 font-weight-light mb-n1">
<h1 class="text-h2 font-weight-bold">home-cert-manager</h1> Welcome to
</div>
<h1 class="text-h2 font-weight-bold">
home-cert-manager
</h1>
</div> </div>
<div class="py-4" /> <div class="py-4" />
@ -26,7 +30,9 @@
</template> </template>
<template #title> <template #title>
<h2 class="text-h5 font-weight-bold">Get started</h2> <h2 class="text-h5 font-weight-bold">
Get started
</h2>
</template> </template>
</v-card> </v-card>
</v-col> </v-col>
@ -39,7 +45,7 @@
rounded="lg" rounded="lg"
title="Manage your certificates" title="Manage your certificates"
variant="text" variant="text"
to="/cert-request" to="/certificates"
/> />
</v-col> </v-col>

View file

@ -5,7 +5,10 @@
<v-form v-model="valid"> <v-form v-model="valid">
<v-container> <v-container>
<v-row> <v-row>
<v-col cols="12" md="4"> <v-col
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.country" v-model="subject.country"
:rules="subject.countryRules" :rules="subject.countryRules"
@ -13,7 +16,10 @@
required required
/> />
</v-col> </v-col>
<v-col cols="12" md="4"> <v-col
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.state" v-model="subject.state"
:counter="10" :counter="10"
@ -23,7 +29,10 @@
/> />
</v-col> </v-col>
<v-col cols="12" md="4"> <v-col
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.city" v-model="subject.city"
:rules="subject.cityRules" :rules="subject.cityRules"
@ -33,7 +42,10 @@
</v-col> </v-col>
</v-row> </v-row>
<v-row> <v-row>
<v-col cols="12" md="4"> <v-col
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.organization" v-model="subject.organization"
:rules="subject.organizationRules" :rules="subject.organizationRules"
@ -41,7 +53,10 @@
required required
/> />
</v-col> </v-col>
<v-col cols="12" md="4"> <v-col
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.orgUnit" v-model="subject.orgUnit"
:rules="subject.orgUnitRules" :rules="subject.orgUnitRules"
@ -49,7 +64,10 @@
required required
/> />
</v-col> </v-col>
<v-col cols="12" md="4"> <v-col
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.commonName" v-model="subject.commonName"
:counter="10" :counter="10"
@ -60,26 +78,48 @@
</v-col> </v-col>
</v-row> </v-row>
<v-row> <v-row>
<v-col cols="12" md="4"> <v-col
<v-text-field type="number" v-model="validity.duration" :rules="validity.durationRules" label="Duration for validity in Days"/> cols="12"
md="4"
>
<v-text-field
v-model="validity.duration"
type="number"
:rules="validity.durationRules"
label="Duration for validity in Days"
/>
</v-col> </v-col>
</v-row> </v-row>
<v-row> <v-row>
<v-col cols="12" md="4"> <v-col
<v-text-field label="Alternative Names" v-model="ui.altName"> cols="12"
<template v-slot:append> md="4"
<v-btn icon="mdi-plus" color="green" :disabled="!ui.altName" @click="addAltName"/> >
<v-text-field
v-model="ui.altName"
label="Alternative Names"
>
<template #append>
<v-btn
icon="mdi-plus"
color="green"
:disabled="!ui.altName"
@click="addAltName"
/>
</template> </template>
</v-text-field> </v-text-field>
</v-col> </v-col>
<v-col cols="12" md="8"> <v-col
cols="12"
md="8"
>
<v-combobox <v-combobox
v-model="subject.altNames" v-model="subject.altNames"
:items="subject.altNames" :items="subject.altNames"
label="Alternative Names" label="Alternative Names"
multiple multiple
> >
<template v-slot:selection="data"> <template #selection="data">
<v-chip <v-chip
:key="JSON.stringify(data.item)" :key="JSON.stringify(data.item)"
v-bind="data" v-bind="data"
@ -92,8 +132,14 @@
</v-col> </v-col>
</v-row> </v-row>
<v-row> <v-row>
<v-col cols="12" md="12"> <v-col
<v-textarea v-model="trustingAuthority" label="Trusting Authority"/> cols="12"
md="12"
>
<v-textarea
v-model="trustingAuthority"
label="Trusting Authority"
/>
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
@ -101,7 +147,12 @@
</v-card-item> </v-card-item>
<v-card-actions> <v-card-actions>
<v-col class="text-right"> <v-col class="text-right">
<v-btn :disabled="!valid" @click="requestCertificate" text="Request certificate" prepend-icon="mdi-certificate"/> <v-btn
:disabled="!valid"
text="Request certificate"
prepend-icon="mdi-certificate"
@click="requestCertificate"
/>
</v-col> </v-col>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
@ -109,7 +160,7 @@
<script lang="ts"> <script lang="ts">
import type { Validated } from "@/types/util"; import type { Validated } from "@/types/util";
import type { Certificate, Subject, Validity } from "@/types/certificate"; import type { Certificate, Subject } from "@/types/certificate";
const requiredValidation = (fieldName: string) => { const requiredValidation = (fieldName: string) => {
return (val: string) => { return (val: string) => {

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { fetchClient, type Schemas } from "@/plugins/client.ts";
import { useRoute } from "vue-router";
const route = useRoute();
const certificate = ref<Schemas["Certificate"] | null>(null);
async function getCertificate() {
const fingerprint = route.params.fingerprint as string;
const response = await fetchClient.GET("/api/certificates/{fingerprint}", {
params: {
path: {
fingerprint,
}
}
});
certificate.value = response.data ?? null;
}
onMounted(() => {
getCertificate()
});
</script>
<template>
<main>
<form v-if="certificate">
<h2>Details des Zertifikats: {{ certificate.fingerprint }}</h2>
</form>
</main>
</template>

View file

@ -0,0 +1,113 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { fetchClient, type Schemas } from "@/plugins/client.ts";
import { useRouter } from "vue-router";
const certificates = ref<Schemas["Certificate"][]>([]);
async function getCertificates() {
const response = await fetchClient.GET("/api/certificates");
certificates.value = response.data?.content ?? [];
}
onMounted(() => {
getCertificates();
});
const router = useRouter();
function navigateToNew() {
router.push("/certificates/new");
}
function navigateToImport() {
router.push("/certificates/import");
}
</script>
<template>
<v-container>
<article>
<h2>Currently known certificates</h2>
<section
v-if="certificates.length === 0"
id="no-certs-found"
>
<v-row>
<v-col
cols="12"
class="text-center pt-4"
>
<p class="font-weight-bold">
There seems to be nothing in here.
</p>
<p>Why don't you start with one of the following options.</p>
<v-row class="mt-4">
<v-col
cols="12"
md="6"
>
<v-btn
color="primary"
@click="navigateToNew"
>
Request new certificate
</v-btn>
</v-col>
<v-col
cols="12"
md="6"
>
<v-btn
color="primary"
@click="navigateToImport"
>
Import an existing certificate
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
</section>
<section
v-if="certificates.length > 0"
id="known-certificates"
>
<ul>
<li
v-for="cert in certificates"
:key="cert.fingerprint"
>
<p>{{ cert.fingerprint }}</p>
</li>
</ul>
<v-row>
<v-col
cols="12"
md="6"
>
<v-btn @click="navigateToNew">
Request a new certificate
</v-btn>
</v-col>
<v-col
cols="12"
md="6"
>
<v-btn
color="primary"
@click="navigateToImport"
>
Import an existing certificate
</v-btn>
</v-col>
</v-row>
</section>
</article>
</v-container>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import CertificateEditor from "@/components/CertificateEditor.vue";
import { useMutation } from "@tanstack/vue-query";
import { fetchClient } from "@/plugins/client.ts";
const submitNewCert = useMutation({
mutationKey: ["submit-new-certificate"],
mutationFn: async (certificate) => {
fetchClient.POST("/api/certificates", {
body: certificate,
})
},
})
function submitCertificate({ certificate }) {
submitNewCert.mutate(certificate);
}
</script>
<template>
<v-container>
<CertificateEditor @submit="submitCertificate" :is-submitting="submitNewCert.isPending" />
</v-container>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,18 @@
import createFetchClient from "openapi-fetch";
import type { components, paths } from "@/generated/api-spec.ts";
import type { App } from "vue";
export const fetchClient = createFetchClient<paths>({
baseUrl: "http://localhost:8080"
});
export type Schemas = components["schemas"];
// noinspection JSUnusedGlobalSymbols
const $api = {
install: (app: App) => {
app.config.globalProperties.$api = fetchClient;
}
}
export default $api;

View file

@ -1,10 +1,14 @@
import vuetify from './vuetify' import vuetify from "./vuetify";
import router from '../router' import router from "../router";
import client from "./client";
import type { App } from 'vue' import type { App } from "vue";
import { VueQueryPlugin } from "@tanstack/vue-query";
export function registerPlugins(app: App) { export function registerPlugins(app: App) {
app app
.use(vuetify) .use(vuetify)
.use(router) .use(router)
.use(client)
.use(VueQueryPlugin);
} }

View file

@ -13,9 +13,7 @@ export interface Validity {
to: Date; to: Date;
} }
export interface Extensions { export type Extensions = object;
}
export interface Certificate { export interface Certificate {
issuer: Subject; issuer: Subject;

View file

@ -0,0 +1,50 @@
import iso3166 from "iso-3166-1";
/**
* While the iso-3166-1 library does provide a country interface, said interface is not exported.
* We re-define it to make it usable for our purposes.
*/
export type Country = ReturnType<typeof iso3166.all>[number];
// Define interface for divider/header items
export interface CountryDivider {
divider: boolean;
header: string;
}
// Define a type that can be either a Country or a Divider
export type CountryListItemType = Country | CountryDivider;
// Get all countries from iso3166
export const allCountries = iso3166.all() as Country[];
// Get user's locale from browser (module-scoped)
export const userLocale = navigator.language || navigator.languages?.[0] || 'en-US';
export const userCountryCode = userLocale.split('-')[1]?.toUpperCase() || '';
// Find user's country based on browser locale (module-scoped)
export const userCountry = allCountries.find(c => c.alpha2 === userCountryCode);
/**
* Prepares a list of countries with the user's country at the top (if available)
* @returns An array of countries and dividers for use in selection components
*/
export function getAvailableCountries(): CountryListItemType[] {
// Create the country list
const countries: CountryListItemType[] = [];
// Add user's country at the top if found
if (userCountry) {
countries.push(userCountry);
countries.push({ divider: true, header: 'All Countries' });
// Add all countries except the user's country to avoid duplicates
countries.push(...allCountries.filter(c => c.alpha2 !== userCountryCode));
} else {
// Add all countries if user's country not found
countries.push(...allCountries);
}
return countries;
}

View file

@ -1,10 +1,11 @@
{ {
"extends": "@vue/tsconfig/tsconfig.dom.json", "extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"], "include": ["env.d.ts", "src/**/*", "src/**/*.ts", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"], "exclude": ["src/**/__tests__/*"],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"noUncheckedIndexedAccess": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {

View file

@ -1,24 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
declare module 'vue-router/auto-routes' {
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
/**
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/cert-request': RouteRecordInfo<'/cert-request', '/cert-request', Record<never, never>, Record<never, never>>,
}
}

View file

@ -2,6 +2,7 @@ import Components from 'unplugin-vue-components/vite'
import Vue from '@vitejs/plugin-vue' import Vue from '@vitejs/plugin-vue'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import ViteFonts from 'unplugin-fonts/vite' import ViteFonts from 'unplugin-fonts/vite'
import VueDevTools from 'vite-plugin-vue-devtools';
import VueRouter from 'unplugin-vue-router/vite' import VueRouter from 'unplugin-vue-router/vite'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
@ -9,7 +10,9 @@ import { fileURLToPath, URL } from 'node:url'
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
VueRouter(), VueRouter({
dts: './src/generated/typed-routes.d.ts'
}),
Vue({ Vue({
template: { transformAssetUrls }, template: { transformAssetUrls },
}), }),
@ -19,7 +22,9 @@ export default defineConfig({
configFile: 'src/styles/settings.scss', configFile: 'src/styles/settings.scss',
}, },
}), }),
Components(), Components({
dts: './src/generated/components.d.ts',
}),
ViteFonts({ ViteFonts({
google: { google: {
families: [ { families: [ {
@ -28,6 +33,9 @@ export default defineConfig({
}], }],
}, },
}), }),
VueDevTools({
launchEditor: process.env.DEVTOOLS_LAUNCH_EDITOR ?? 'idea',
}),
], ],
define: { 'process.env': {} }, define: { 'process.env': {} },
resolve: { resolve: {

View file

@ -0,0 +1,35 @@
package de.mlessmann.certassist.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.MapperFeature;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfiguration {
/**
* Customizes the objectMapper so that ONLY specifically annotated fields are serialized.
* Other fields MUST NOT be serialized since they may contain sensitive information!
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizeObjectMapper() {
return builder -> builder
.featuresToDisable(
MapperFeature.AUTO_DETECT_FIELDS,
MapperFeature.AUTO_DETECT_GETTERS,
MapperFeature.AUTO_DETECT_IS_GETTERS
)
.serializationInclusion(JsonInclude.Include.NON_EMPTY)
.visibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE)
.visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.NONE)
.visibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE)
.visibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE)
.visibility(PropertyAccessor.SETTER, JsonAutoDetect.Visibility.PUBLIC_ONLY)
.visibility(PropertyAccessor.CREATOR, JsonAutoDetect.Visibility.PUBLIC_ONLY)
.build();
}
}

View file

@ -0,0 +1,23 @@
package de.mlessmann.certassist.config;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class SecurityConfiguration {
@Bean
public SecurityFilterChain securityFilters(HttpSecurity http) throws Exception {
// Allow unauthenticated access to OpenAPI and swagger documentation.
// This should be removed or at least configurable at some point, but for now, this is fine (tm)
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/v3/api-docs/**", "/v3/api-docs.yaml", "/swagger-ui/**", "/swagger-ui.html")
.permitAll());
return http.build();
}
}

View file

@ -0,0 +1,27 @@
package de.mlessmann.certassist.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.core.jackson.ModelResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Configuration for Springdoc OpenAPI to respect Jackson annotations.
* This ensures that only properties that are visible to Jackson (annotated with @JsonProperty)
* are included in the OpenAPI schema.
*/
@Configuration
public class SpringdocConfiguration {
/**
* Creates a ModelResolver that uses the same ObjectMapper as the application.
* This ensures that Springdoc respects the same visibility settings as Jackson.
*
* @param objectMapper the configured ObjectMapper from JacksonConfiguration
* @return a ModelResolver that uses the application's ObjectMapper
*/
@Bean
public ModelResolver modelResolver(ObjectMapper objectMapper) {
return new ModelResolver(objectMapper);
}
}

View file

@ -1,14 +1,18 @@
package de.mlessmann.certassist.models; package de.mlessmann.certassist.models;
import com.fasterxml.jackson.annotation.JsonProperty;
import de.mlessmann.certassist.web.JsonIsoOffsetDate;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*;
import org.hibernate.proxy.HibernateProxy;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import lombok.*;
import org.hibernate.proxy.HibernateProxy;
@Entity @Entity
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"fingerprint"})}) @Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"fingerprint"})})
@ -25,8 +29,10 @@ public class Certificate {
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@JsonProperty
private CertificateType type; private CertificateType type;
@JsonProperty
private String trustingAuthority; private String trustingAuthority;
/** /**
@ -39,17 +45,26 @@ public class Certificate {
@Min(-1) @Min(-1)
private int requestedKeyLength; private int requestedKeyLength;
@JsonIsoOffsetDate
private OffsetDateTime notBefore; private OffsetDateTime notBefore;
@JsonIsoOffsetDate
private OffsetDateTime notAfter; private OffsetDateTime notAfter;
@NotNull @NotNull
@JsonProperty
private String subjectCommonName; private String subjectCommonName;
@JsonProperty
private String subjectEmailAddress; private String subjectEmailAddress;
@JsonProperty
private String subjectOrganization; private String subjectOrganization;
@JsonProperty
private String subjectOrganizationalUnit; private String subjectOrganizationalUnit;
@JsonProperty
private String subjectCountry; private String subjectCountry;
@JsonProperty
private String subjectState; private String subjectState;
@JsonProperty
private String subjectLocality; private String subjectLocality;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@ -69,6 +84,8 @@ public class Certificate {
private byte[] fullchain; private byte[] fullchain;
@Column(nullable = false) @Column(nullable = false)
@JsonProperty
@Schema(description = "The certificate fingerprint. The algorithm used to derive the fingerprint is determined by OpenSSL")
private String fingerprint; private String fingerprint;
@Override @Override

View file

@ -1,10 +1,11 @@
package de.mlessmann.certassist.repositories; package de.mlessmann.certassist.repositories;
import de.mlessmann.certassist.models.Certificate; import de.mlessmann.certassist.models.Certificate;
import org.springframework.data.repository.CrudRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@Repository @Repository
public interface CertificateRepository extends CrudRepository<Certificate, String> { public interface CertificateRepository extends JpaRepository<Certificate, String> {
Certificate findByFingerprintIs(String fingerprint); Certificate findByFingerprintIs(String fingerprint);
} }

View file

@ -0,0 +1,100 @@
package de.mlessmann.certassist.web;
import de.mlessmann.certassist.models.Certificate;
import de.mlessmann.certassist.models.CertificateInfo;
import de.mlessmann.certassist.models.CertificateInfoSubject;
import de.mlessmann.certassist.repositories.CertificateRepository;
import de.mlessmann.certassist.service.CertificateCreationService;
import de.mlessmann.certassist.web.dto.PrivateKey;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class CertificatesEndpoint {
public static final String MIME_PEM_FILE = "application/x-pem-file";
private final CertificateRepository certificateRepository;
private final CertificateCreationService certificateCreationService;
@GetMapping("/certificates")
@Operation(description = "Fetches certificates", responses = {
@ApiResponse(responseCode = "200", description = "Contains the returned certificates of the requested page.")
})
public ResponseEntity<DocumentedPage<Certificate>> getCertificates(@ParameterObject Pageable pageable) {
var certificates = certificateRepository.findAll(pageable);
return ResponseEntity.ok(DocumentedPage.of(certificates));
}
@GetMapping("/certificates/{fingerprint}")
@Operation(
description = "Fetches a single certificate by the provided fingerprint",
responses = {
@ApiResponse(responseCode = "200")
}
)
public ResponseEntity<Certificate> getCertificate(@PathVariable String fingerprint) {
var certificate = certificateRepository.findByFingerprintIs(fingerprint);
return ResponseEntity.ok(certificate);
}
@PostMapping("/certificates")
@Operation(description = "Requests a new certificate", responses = {
@ApiResponse(responseCode = "400", description = "One of the provided parameters is invalid."),
@ApiResponse(responseCode = "200", description = "Returns the newly created certificate.")
})
public ResponseEntity<Certificate> createCertificate(@RequestBody CertificateInfo request) {
var createdCertificate = certificateCreationService.createCertificate(
CertificateInfo.builder()
.type(CertificateInfo.RequestType.STANDALONE_CERTIFICATE)
.issuer(CertificateInfoSubject.builder()
.commonName("Test")
)
.build()
);
return ResponseEntity.ok(createdCertificate);
}
@GetMapping(value = "/certificates/{cert}/privateKey", produces = {
MimeTypeUtils.APPLICATION_JSON_VALUE,
MIME_PEM_FILE
})
@Operation(description = "Fetches the private key corresponding to the provided certificate.",
responses = {
@ApiResponse(responseCode = "200", content = {
@Content(mediaType = MimeTypeUtils.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = PrivateKey.class)),
@Content(mediaType = MIME_PEM_FILE,
schema = @Schema(type = "string", description = "PEM formatted private key content"))
})
})
public ResponseEntity<Object> getCertificatePrivateKey(
@RequestHeader("Accept") String acceptType,
@PathVariable String cert
) {
var requestedCert = certificateRepository.findByFingerprintIs(cert);
if (MimeTypeUtils.APPLICATION_JSON_VALUE.equals(acceptType)) {
String pemContent = new String(requestedCert.getPrivateKey(), StandardCharsets.UTF_8);
var pKey = new PrivateKey(pemContent);
return ResponseEntity.ok(pKey);
} else if (MIME_PEM_FILE.equals(acceptType)) {
String pemContent = new String(requestedCert.getPrivateKey(), StandardCharsets.UTF_8);
return ResponseEntity.ok(pemContent);
} else {
return ResponseEntity.badRequest().build();
}
}
}

View file

@ -0,0 +1,29 @@
package de.mlessmann.certassist.web;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.data.domain.Page;
import java.util.List;
public interface DocumentedPage<T> extends Page<T> {
@Override
@JsonProperty
@Schema(description = "The content of the paginated response. See nested type for more information.")
List<T> getContent();
@Override
@JsonProperty("size")
@Schema(description = "How many items there are in this current page.")
int getNumberOfElements();
@Override
@JsonProperty
@Schema(description = "How many pages are currently available in total.")
int getTotalPages();
static <T> DocumentedPage<T> of(Page<T> page) {
return (DocumentedPage<T>) page;
}
}

View file

@ -0,0 +1,19 @@
package de.mlessmann.certassist.web;
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.time.format.DateTimeFormatter;
@JacksonAnnotationsInside
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@JsonProperty
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
public @interface JsonIsoOffsetDate {
}

View file

@ -0,0 +1,11 @@
package de.mlessmann.certassist.web.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "Represents the private key of a certificate.")
public record PrivateKey(
@JsonProperty
@Schema(description = "The content of the private key as it would be in a .pem file.")
String pemContent) {
}