Add import/export functionality for apps, including client-side file handling and backend encryption/decryption logic

This commit is contained in:
Jan 2025-12-17 16:06:59 +01:00
parent 1788a7ef1c
commit 6add528c02
7 changed files with 287 additions and 14 deletions

View file

@ -1,10 +1,20 @@
<template> <template>
<div> <div>
<div class="app-list-item"> <div class="app-list-item">
<div class="app-name-container"><div class="app-name-name">{{ app.name }}</div><div class="app-name-id">{{ app.client_id}}</div></div> <div class="app-name-container">
<div class="app-name-name">{{ app.name }}</div>
<div class="app-name-id">{{ app.client_id }}</div>
</div>
<div class="badge-list">
<basic-badge variant="secondary" icon="lock" v-for="group in groups" :key="group">{{ group }}</basic-badge>
</div>
<div class="action-container">
<icon-button icon="download" @click="exportClick"></icon-button>
<icon-button icon="trash" @click="deleteClick"></icon-button>
</div>
<div class="badge-list"> <basic-badge variant="secondary" icon="lock" v-for="group in groups" :key="group">{{group}}</basic-badge></div>
<div class="action-container"> <icon-button icon="trash" @click="deleteClick"></icon-button></div>
</div> </div>
</div> </div>
@ -18,7 +28,7 @@ import BasicBadge from "@/components/UI/BasicBadge.vue";
export default { export default {
name: "AppListItem", name: "AppListItem",
components: {BasicBadge, IconButton, Box}, components: {BasicBadge, IconButton, Box},
emits: ["deleteApp"], emits: ["deleteApp", "exportApp"],
props: { props: {
app: { app: {
type: Object, type: Object,
@ -33,6 +43,9 @@ export default {
methods: { methods: {
deleteClick() { deleteClick() {
this.$emit("deleteApp", this.app.id); this.$emit("deleteApp", this.app.id);
},
exportClick() {
this.$emit("exportApp", this.app.id);
} }
} }
} }
@ -76,7 +89,10 @@ export default {
color: #6b7280; color: #6b7280;
} }
.action-container{ .action-container {
display: flex;
flex-direction: row;
gap: 1.2rem;
align-self: center; align-self: center;
} }

View file

@ -1,6 +1,8 @@
<template> <template>
<div class="apps-container"> <div class="apps-container">
<div class="app-list-actions">
</div>
<div class="app-list-header"> <div class="app-list-header">
<div>App</div> <div>App</div>
<div>Groups</div> <div>Groups</div>
@ -8,13 +10,20 @@
</div> </div>
<div class="app-list"> <div class="app-list">
<app-list-item v-for="app in apps" :app="app" @delete-app="deleteApp"></app-list-item> <app-list-item v-for="app in apps" :app="app" @delete-app="deleteApp" @export-app="exportApp"></app-list-item>
</div> </div>
<modal :state="modalState"> <modal :state="modalState">
<add-app @close="closeModal"></add-app> <add-app @close="closeModal"></add-app>
</modal> </modal>
<basic-button icon="Plus" @click="modalState = true">New App</basic-button>
<div class="app-list-actions">
<basic-button icon="Upload" @click="importApp">Import</basic-button>
<basic-button icon="Plus" @click="modalState = true">New App</basic-button>
</div>
</div> </div>
@ -27,6 +36,8 @@ import {mapStores} from "pinia";
import {useAppsStore} from "@/store/apps.js"; import {useAppsStore} from "@/store/apps.js";
import Modal from "@/components/UI/Modal.vue"; import Modal from "@/components/UI/Modal.vue";
import AddApp from "@/components/layout/config/AddApp.vue"; import AddApp from "@/components/layout/config/AddApp.vue";
import Dropdown from "@/components/UI/Dropdown.vue";
import IconButton from "@/components/UI/IconButton.vue";
export default { export default {
name: "Apps", name: "Apps",
@ -36,7 +47,7 @@ export default {
default: false default: false
} }
}, },
components: {AddApp, Modal, AppListItem, BasicButton}, components: {IconButton, Dropdown, AddApp, Modal, AppListItem, BasicButton},
computed: { computed: {
...mapStores(useAppsStore), ...mapStores(useAppsStore),
apps() { apps() {
@ -45,7 +56,8 @@ export default {
}, },
data() { data() {
return { return {
modalState: false modalState: false,
exportedApp: null
} }
}, },
methods: { methods: {
@ -57,6 +69,62 @@ export default {
}, },
deleteApp(id) { deleteApp(id) {
this.appsStore.deleteApp(id); this.appsStore.deleteApp(id);
},
async exportApp(id) {
const response = await this.appsStore.exportApp(id);
const app = this.appsStore.getById(id);
if(response?.data) {
const base64String = response.data;
const blob = new Blob([base64String], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${app.name}.app`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
},
async importApp() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.app';
input.onchange = async (event) => {
const file = event.target.files[0];
if (file) {
try {
const fileContent = await this.readFileContent(file);
await this.appsStore.importApp(fileContent);
} catch (error) {
}
}
};
// File Dialog öffnen
input.click();
},
readFileContent(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve(e.target.result);
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsText(file);
});
} }
}, },
async created() { async created() {
@ -85,6 +153,13 @@ export default {
border-bottom: 0.1rem solid #E3EDFF; border-bottom: 0.1rem solid #E3EDFF;
} }
.app-list-actions {
display: flex;
justify-content: flex-end;
margin-top: 2rem;
gap: 1.6rem
}
.app-list { .app-list {
margin-bottom: 2.4rem; margin-bottom: 2.4rem;
} }

View file

@ -24,6 +24,18 @@ export const useAppsStore = defineStore('apps', {
this.apps = resp.data; this.apps = resp.data;
this.loading = false; this.loading = false;
}, },
async exportApp(id) {
const url = `${config.backendUrl}/apps/export/${id}`;
const resp = await performRequest(this, 'GET', url, null);
return resp.data;
},
async importApp(app) {
const url = `${config.backendUrl}/apps/import`;
const resp = await performRequest(this, 'POST', url, { data: app },true);
if(resp.data)
await this.loadApps();
},
async addApp(appName, appGroups) { async addApp(appName, appGroups) {
const url = `${config.backendUrl}/apps`; const url = `${config.backendUrl}/apps`;

View file

@ -2,6 +2,7 @@ package de.avatic.lcc.controller.configuration;
import com.azure.core.annotation.BodyParam; import com.azure.core.annotation.BodyParam;
import de.avatic.lcc.dto.configuration.apps.AppDTO; import de.avatic.lcc.dto.configuration.apps.AppDTO;
import de.avatic.lcc.dto.configuration.apps.AppExchangeDTO;
import de.avatic.lcc.service.apps.AppsService; import de.avatic.lcc.service.apps.AppsService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@ -32,6 +33,18 @@ public class AppsController {
return ResponseEntity.ok(appsService.updateApp(dto)); return ResponseEntity.ok(appsService.updateApp(dto));
} }
@GetMapping({"/export/{id}", "/export/{id}/"})
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<AppExchangeDTO> exportApp(@PathVariable Integer id) {
return ResponseEntity.ok(appsService.exportApp(id));
}
@PostMapping({"/import/", "/import"})
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<Boolean> importApp(@RequestBody AppExchangeDTO dto) {
return ResponseEntity.ok(appsService.importApp(dto));
}
@DeleteMapping({"/{id}", "/{id}/"}) @DeleteMapping({"/{id}", "/{id}/"})
@PreAuthorize("hasRole('SERVICE')") @PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<Void> deleteApp(@PathVariable Integer id) { public ResponseEntity<Void> deleteApp(@PathVariable Integer id) {

View file

@ -1,4 +1,14 @@
package de.avatic.lcc.dto.configuration.apps; package de.avatic.lcc.dto.configuration.apps;
public class AppExchangeDTO { public class AppExchangeDTO {
private String data;
public void setData(String data) {
this.data = data;
}
public String getData() {
return data;
}
} }

View file

@ -1,13 +1,23 @@
package de.avatic.lcc.service.apps; package de.avatic.lcc.service.apps;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.avatic.lcc.dto.configuration.apps.AppDTO; import de.avatic.lcc.dto.configuration.apps.AppDTO;
import de.avatic.lcc.dto.configuration.apps.AppExchangeDTO;
import de.avatic.lcc.model.db.users.App; import de.avatic.lcc.model.db.users.App;
import de.avatic.lcc.repositories.users.AppRepository; import de.avatic.lcc.repositories.users.AppRepository;
import de.avatic.lcc.service.transformer.apps.AppTransformer; import de.avatic.lcc.service.transformer.apps.AppTransformer;
import de.avatic.lcc.util.exception.base.InternalErrorException;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.security.SecureRandom; import javax.crypto.*;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -16,14 +26,31 @@ import java.util.UUID;
@Service @Service
public class AppsService { public class AppsService {
private static final String HMAC_ALGORITHM = "HmacSHA256";
private static final String ENCRYPTION_ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_TAG_LENGTH = 128;
private static final int GCM_IV_LENGTH = 12;
private final AppRepository appRepository; private final AppRepository appRepository;
private final AppTransformer appTransformer; private final AppTransformer appTransformer;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final ObjectMapper objectMapper;
private final Key signingKey;
private final SecretKeySpec encryptionKey;
public AppsService(AppRepository appRepository, AppTransformer appTransformer, PasswordEncoder passwordEncoder) {
public AppsService(@Value("${jwt.secret}") String secret, AppRepository appRepository, AppTransformer appTransformer, PasswordEncoder passwordEncoder, ObjectMapper objectMapper) {
this.appRepository = appRepository; this.appRepository = appRepository;
this.appTransformer = appTransformer; this.appTransformer = appTransformer;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.objectMapper = objectMapper;
// AES-256 Key aus JWT Secret ableiten
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
byte[] aesKey = new byte[32]; // 256 bit
System.arraycopy(keyBytes, 0, aesKey, 0, Math.min(keyBytes.length, 32));
this.encryptionKey = new SecretKeySpec(aesKey, "AES");
} }
public List<AppDTO> listApps() { public List<AppDTO> listApps() {
@ -35,7 +62,7 @@ public class AppsService {
var newApp = dto.getId() == null; var newApp = dto.getId() == null;
String appSecret = null; String appSecret = null;
if(newApp) { if (newApp) {
dto.setClientId(generateAppId()); dto.setClientId(generateAppId());
appSecret = generateAppSecret(); appSecret = generateAppSecret();
dto.setClientSecret(passwordEncoder.encode(appSecret)); dto.setClientSecret(passwordEncoder.encode(appSecret));
@ -43,7 +70,7 @@ public class AppsService {
var id = appRepository.update(appTransformer.toAppEntity(dto)); var id = appRepository.update(appTransformer.toAppEntity(dto));
if(newApp) { if (newApp) {
dto.setId(id); dto.setId(id);
dto.setClientSecret(appSecret); dto.setClientSecret(appSecret);
} }
@ -79,4 +106,103 @@ public class AppsService {
} }
public AppExchangeDTO exportApp(Integer id) {
var app = appRepository.getById(id).map(appTransformer::toAppDTOWithHashedSecret);
if (app.isEmpty()) {
throw new IllegalArgumentException("App mit ID " + id + " nicht gefunden");
}
AppExchangeDTO exchangeDTO = new AppExchangeDTO();
try {
String json = objectMapper.writeValueAsString(app);
byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM);
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, gcmSpec);
byte[] encryptedData = cipher.doFinal(jsonBytes);
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(signingKey.getEncoded(), HMAC_ALGORITHM));
mac.update(iv);
mac.update(encryptedData);
byte[] signature = mac.doFinal();
byte[] bundle = new byte[iv.length + encryptedData.length + signature.length];
System.arraycopy(iv, 0, bundle, 0, iv.length);
System.arraycopy(encryptedData, 0, bundle, iv.length, encryptedData.length);
System.arraycopy(signature, 0, bundle, iv.length + encryptedData.length, signature.length);
exchangeDTO.setData(Base64.getEncoder().encodeToString(bundle));
} catch (JsonProcessingException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException _) {
throw new InternalErrorException("Fehler beim Exportieren der App");
}
return exchangeDTO;
}
public boolean importApp(AppExchangeDTO exchangeDTO) {
try {
byte[] bundle = Base64.getDecoder().decode(exchangeDTO.getData());
// 2. Bundle aufteilen
if (bundle.length < GCM_IV_LENGTH + 32) {
throw new IllegalArgumentException("Ungültiges Export-Bundle");
}
byte[] iv = new byte[GCM_IV_LENGTH];
byte[] signature = new byte[32];
byte[] encryptedData = new byte[bundle.length - GCM_IV_LENGTH - 32];
System.arraycopy(bundle, 0, iv, 0, GCM_IV_LENGTH);
System.arraycopy(bundle, GCM_IV_LENGTH, encryptedData, 0, encryptedData.length);
System.arraycopy(bundle, GCM_IV_LENGTH + encryptedData.length, signature, 0, 32);
// 3. Signatur verifizieren
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(signingKey.getEncoded(), HMAC_ALGORITHM));
mac.update(iv);
mac.update(encryptedData);
byte[] expectedSignature = mac.doFinal();
if (!java.security.MessageDigest.isEqual(signature, expectedSignature)) {
throw new SecurityException("Ungültige Signatur - Daten wurden manipuliert oder stammen nicht von dieser Instanz");
}
// 4. Entschlüsseln
Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, encryptionKey, gcmSpec);
byte[] decryptedData = cipher.doFinal(encryptedData);
// 5. JSON deserialisieren
String json = new String(decryptedData, StandardCharsets.UTF_8);
AppDTO dto = objectMapper.readValue(json, AppDTO.class);
// 6. Prüfen ob App mit dieser Client-ID bereits existiert
var existingApp = appRepository.getByClientId(dto.getClientId());
if (existingApp.isPresent()) {
throw new IllegalStateException(
"App mit Client-ID '" + dto.getClientId() + "' existiert bereits"
);
}
App app = appTransformer.toAppEntityWithHashedSecret(dto);
appRepository.update(app);
} catch (JsonProcessingException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException _) {
return false;
}
return true;
}
} }

View file

@ -14,7 +14,6 @@ public class AppTransformer {
dto.setId(entity.getId()); dto.setId(entity.getId());
dto.setName(entity.getName()); dto.setName(entity.getName());
dto.setClientSecret(entity.getClientSecret());
dto.setClientId(entity.getClientId()); dto.setClientId(entity.getClientId());
dto.setGroups(entity.getGroups().stream().map(Group::getName).toList()); dto.setGroups(entity.getGroups().stream().map(Group::getName).toList());
@ -38,4 +37,26 @@ public class AppTransformer {
group.setName(name); group.setName(name);
return group; return group;
} }
public AppDTO toAppDTOWithHashedSecret(App entity) {
AppDTO dto = new AppDTO();
dto.setName(entity.getName());
dto.setClientSecret(entity.getClientSecret());
dto.setClientId(entity.getClientId());
dto.setGroups(entity.getGroups().stream().map(Group::getName).toList());
return dto;
}
public App toAppEntityWithHashedSecret(AppDTO dto) {
App entity = new App();
entity.setName(dto.getName());
entity.setClientSecret(dto.getClientSecret());
entity.setClientId(dto.getClientId());
entity.setGroups(dto.getGroups().stream().map(this::fromGroupDTO).toList());
return entity;
}
} }