Compare commits
19 commits
main
...
feat/openA
Author | SHA1 | Date | |
---|---|---|---|
22eae92cba | |||
651c205656 | |||
13efdd06b9 | |||
0e60183372 | |||
ed948d642a | |||
c923c2dbf4 | |||
b0650a5bf4 | |||
e7ada4b47d | |||
46f31de837 | |||
b997a5c273 | |||
dca9845fe9 | |||
b6db17e7d8 | |||
e91bf96e74 | |||
532d37ce81 | |||
8a843dc300 | |||
5dde208e72 | |||
c462614d8d | |||
a886eab7a2 | |||
b7a63892a4 |
29 changed files with 2150 additions and 91 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -40,3 +40,6 @@ out/
|
||||||
### Test files ###
|
### Test files ###
|
||||||
sqLiteDb.db
|
sqLiteDb.db
|
||||||
dev/
|
dev/
|
||||||
|
|
||||||
|
### Development settings ###
|
||||||
|
application.properties
|
|
@ -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
3
frontend/.gitignore
vendored
|
@ -20,3 +20,6 @@ pnpm-debug.log*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Generated source files
|
||||||
|
src/generated/
|
|
@ -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
1209
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -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 />
|
||||||
|
|
276
frontend/src/components/CertificateEditor.vue
Normal file
276
frontend/src/components/CertificateEditor.vue
Normal 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>
|
44
frontend/src/components/CountryListItem.vue
Normal file
44
frontend/src/components/CountryListItem.vue
Normal 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>
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
34
frontend/src/pages/certificates/[fingerprint].vue
Normal file
34
frontend/src/pages/certificates/[fingerprint].vue
Normal 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>
|
113
frontend/src/pages/certificates/index.vue
Normal file
113
frontend/src/pages/certificates/index.vue
Normal 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>
|
29
frontend/src/pages/certificates/new.vue
Normal file
29
frontend/src/pages/certificates/new.vue
Normal 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>
|
18
frontend/src/plugins/client.ts
Normal file
18
frontend/src/plugins/client.ts
Normal 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;
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
4
frontend/src/types/certificate.d.ts
vendored
4
frontend/src/types/certificate.d.ts
vendored
|
@ -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;
|
||||||
|
|
50
frontend/src/utils/countries.ts
Normal file
50
frontend/src/utils/countries.ts
Normal 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;
|
||||||
|
}
|
|
@ -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": {
|
||||||
|
|
24
frontend/typed-router.d.ts
vendored
24
frontend/typed-router.d.ts
vendored
|
@ -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>>,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +1,21 @@
|
||||||
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"})})
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@ToString
|
@ToString
|
||||||
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue