Add import/export functionality for apps, including client-side file handling and backend encryption/decryption logic
This commit is contained in:
parent
1788a7ef1c
commit
6add528c02
7 changed files with 287 additions and 14 deletions
|
|
@ -1,10 +1,20 @@
|
|||
<template>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
|
|
@ -18,7 +28,7 @@ import BasicBadge from "@/components/UI/BasicBadge.vue";
|
|||
export default {
|
||||
name: "AppListItem",
|
||||
components: {BasicBadge, IconButton, Box},
|
||||
emits: ["deleteApp"],
|
||||
emits: ["deleteApp", "exportApp"],
|
||||
props: {
|
||||
app: {
|
||||
type: Object,
|
||||
|
|
@ -33,6 +43,9 @@ export default {
|
|||
methods: {
|
||||
deleteClick() {
|
||||
this.$emit("deleteApp", this.app.id);
|
||||
},
|
||||
exportClick() {
|
||||
this.$emit("exportApp", this.app.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +89,10 @@ export default {
|
|||
color: #6b7280;
|
||||
}
|
||||
|
||||
.action-container{
|
||||
.action-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1.2rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<template>
|
||||
<div class="apps-container">
|
||||
<div class="app-list-actions">
|
||||
|
||||
</div>
|
||||
<div class="app-list-header">
|
||||
<div>App</div>
|
||||
<div>Groups</div>
|
||||
|
|
@ -8,13 +10,20 @@
|
|||
</div>
|
||||
<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>
|
||||
|
||||
<modal :state="modalState">
|
||||
<add-app @close="closeModal"></add-app>
|
||||
</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>
|
||||
|
||||
|
||||
|
|
@ -27,6 +36,8 @@ import {mapStores} from "pinia";
|
|||
import {useAppsStore} from "@/store/apps.js";
|
||||
import Modal from "@/components/UI/Modal.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 {
|
||||
name: "Apps",
|
||||
|
|
@ -36,7 +47,7 @@ export default {
|
|||
default: false
|
||||
}
|
||||
},
|
||||
components: {AddApp, Modal, AppListItem, BasicButton},
|
||||
components: {IconButton, Dropdown, AddApp, Modal, AppListItem, BasicButton},
|
||||
computed: {
|
||||
...mapStores(useAppsStore),
|
||||
apps() {
|
||||
|
|
@ -45,7 +56,8 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
modalState: false
|
||||
modalState: false,
|
||||
exportedApp: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -57,6 +69,62 @@ export default {
|
|||
},
|
||||
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() {
|
||||
|
|
@ -85,6 +153,13 @@ export default {
|
|||
border-bottom: 0.1rem solid #E3EDFF;
|
||||
}
|
||||
|
||||
.app-list-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
gap: 1.6rem
|
||||
}
|
||||
|
||||
.app-list {
|
||||
margin-bottom: 2.4rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,18 @@ export const useAppsStore = defineStore('apps', {
|
|||
this.apps = resp.data;
|
||||
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) {
|
||||
const url = `${config.backendUrl}/apps`;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package de.avatic.lcc.controller.configuration;
|
|||
|
||||
import com.azure.core.annotation.BodyParam;
|
||||
import de.avatic.lcc.dto.configuration.apps.AppDTO;
|
||||
import de.avatic.lcc.dto.configuration.apps.AppExchangeDTO;
|
||||
import de.avatic.lcc.service.apps.AppsService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
|
@ -32,6 +33,18 @@ public class AppsController {
|
|||
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}/"})
|
||||
@PreAuthorize("hasRole('SERVICE')")
|
||||
public ResponseEntity<Void> deleteApp(@PathVariable Integer id) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
package de.avatic.lcc.dto.configuration.apps;
|
||||
|
||||
public class AppExchangeDTO {
|
||||
|
||||
private String data;
|
||||
|
||||
public void setData(String data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public String getData() {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
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.AppExchangeDTO;
|
||||
import de.avatic.lcc.model.db.users.App;
|
||||
import de.avatic.lcc.repositories.users.AppRepository;
|
||||
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.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.List;
|
||||
import java.util.Optional;
|
||||
|
|
@ -16,14 +26,31 @@ import java.util.UUID;
|
|||
@Service
|
||||
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 AppTransformer appTransformer;
|
||||
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.appTransformer = appTransformer;
|
||||
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() {
|
||||
|
|
@ -35,7 +62,7 @@ public class AppsService {
|
|||
var newApp = dto.getId() == null;
|
||||
String appSecret = null;
|
||||
|
||||
if(newApp) {
|
||||
if (newApp) {
|
||||
dto.setClientId(generateAppId());
|
||||
appSecret = generateAppSecret();
|
||||
dto.setClientSecret(passwordEncoder.encode(appSecret));
|
||||
|
|
@ -43,7 +70,7 @@ public class AppsService {
|
|||
|
||||
var id = appRepository.update(appTransformer.toAppEntity(dto));
|
||||
|
||||
if(newApp) {
|
||||
if (newApp) {
|
||||
dto.setId(id);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ public class AppTransformer {
|
|||
|
||||
dto.setId(entity.getId());
|
||||
dto.setName(entity.getName());
|
||||
dto.setClientSecret(entity.getClientSecret());
|
||||
dto.setClientId(entity.getClientId());
|
||||
dto.setGroups(entity.getGroups().stream().map(Group::getName).toList());
|
||||
|
||||
|
|
@ -38,4 +37,26 @@ public class AppTransformer {
|
|||
group.setName(name);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue