From 63cf411f1e1d5e4650be405ecf12bbcb9ca75bc4 Mon Sep 17 00:00:00 2001 From: M3DZIK Date: Tue, 14 Nov 2023 21:36:30 +0100 Subject: [PATCH] Add support for deleting account --- .../dev/medzik/librepass/client/Client.kt | 23 +++++++++++++ .../dev/medzik/librepass/client/api/User.kt | 34 ++++++++++++++++--- .../librepass/client/api/AuthClientTests.kt | 8 +++++ .../librepass/client/api/CipherClientTests.kt | 8 +++++ .../client/api/CollectionClientTests.kt | 9 +++++ .../librepass/client/api/TwoFactorTests.kt | 12 +++++++ .../librepass/client/api/UserClientTests.kt | 9 +++++ .../librepass/server/controllers/api/User.kt | 21 ++++++++++++ .../server/database/CipherRepository.kt | 5 +++ .../server/database/TokenRepository.kt | 5 +++ .../dev/medzik/librepass/types/api/User.kt | 11 ++++++ 11 files changed, 141 insertions(+), 4 deletions(-) diff --git a/client/src/main/kotlin/dev/medzik/librepass/client/Client.kt b/client/src/main/kotlin/dev/medzik/librepass/client/Client.kt index 01363b63..c9373bbe 100644 --- a/client/src/main/kotlin/dev/medzik/librepass/client/Client.kt +++ b/client/src/main/kotlin/dev/medzik/librepass/client/Client.kt @@ -79,6 +79,29 @@ class Client( return executeAndExtractBody(request) } + /** + * Send a DELETE request to the API. + * @param endpoint endpoint of the API + * @param json JSON body of the request + * @return response body + */ + @Throws(ClientException::class, ApiException::class) + fun delete( + endpoint: String, + json: String + ): String { + val body = json.toRequestBody(httpMediaTypeJson) + + val request = + Request.Builder() + .url(apiURL + endpoint) + .addHeader("Authorization", authorizationHeader) + .delete(body) + .build() + + return executeAndExtractBody(request) + } + /** * Send a POST request to the API. * @param endpoint endpoint of the API diff --git a/client/src/main/kotlin/dev/medzik/librepass/client/api/User.kt b/client/src/main/kotlin/dev/medzik/librepass/client/api/User.kt index 943119ca..05fd3734 100644 --- a/client/src/main/kotlin/dev/medzik/librepass/client/api/User.kt +++ b/client/src/main/kotlin/dev/medzik/librepass/client/api/User.kt @@ -12,10 +12,7 @@ import dev.medzik.librepass.client.utils.Cryptography.computeSecretKey import dev.medzik.librepass.client.utils.Cryptography.computeSecretKeyFromPassword import dev.medzik.librepass.client.utils.Cryptography.computeSharedKey import dev.medzik.librepass.client.utils.JsonUtils -import dev.medzik.librepass.types.api.ChangePasswordCipherData -import dev.medzik.librepass.types.api.ChangePasswordRequest -import dev.medzik.librepass.types.api.SetupTwoFactorRequest -import dev.medzik.librepass.types.api.SetupTwoFactorResponse +import dev.medzik.librepass.types.api.* import dev.medzik.librepass.utils.fromHexString import dev.medzik.librepass.utils.toHexString @@ -168,4 +165,33 @@ class UserClient( ) return JsonUtils.deserialize(response) } + + @Throws(ClientException::class, ApiException::class) + fun deleteAccount( + password: String, + tfaCode: String? = null + ) { + val preLogin = AuthClient(apiUrl).preLogin(email) + + val passwordHash = + computePasswordHash( + email = email, + password = password, + argon2Function = preLogin.toArgon2() + ) + + val serverPublicKey = preLogin.serverPublicKey.fromHexString() + val sharedKey = computeSharedKey(passwordHash.hash, serverPublicKey) + + val request = + DeleteAccountRequest( + sharedKey = sharedKey.toHexString(), + code = tfaCode + ) + + client.delete( + "$API_ENDPOINT/delete", + JsonUtils.serialize(request) + ) + } } diff --git a/client/src/test/kotlin/dev/medzik/librepass/client/api/AuthClientTests.kt b/client/src/test/kotlin/dev/medzik/librepass/client/api/AuthClientTests.kt index af569c96..df6156a7 100644 --- a/client/src/test/kotlin/dev/medzik/librepass/client/api/AuthClientTests.kt +++ b/client/src/test/kotlin/dev/medzik/librepass/client/api/AuthClientTests.kt @@ -1,5 +1,6 @@ package dev.medzik.librepass.client.api +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test @@ -17,6 +18,13 @@ class AuthClientTests { // wait for 1 second to prevent unauthorized error Thread.sleep(1000) } + + @AfterAll + @JvmStatic + fun delete() { + val credentials = authClient.login(EMAIL, PASSWORD) + UserClient(EMAIL, credentials.apiKey, API_URL).deleteAccount(PASSWORD) + } } @Test diff --git a/client/src/test/kotlin/dev/medzik/librepass/client/api/CipherClientTests.kt b/client/src/test/kotlin/dev/medzik/librepass/client/api/CipherClientTests.kt index ea47a765..35a0c2ba 100644 --- a/client/src/test/kotlin/dev/medzik/librepass/client/api/CipherClientTests.kt +++ b/client/src/test/kotlin/dev/medzik/librepass/client/api/CipherClientTests.kt @@ -41,6 +41,14 @@ class CipherClientTests { cipherClient.delete(it.id) } } + + @AfterAll + @JvmStatic + fun delete() { + val authClient = AuthClient(API_URL) + val credentials = authClient.login(EMAIL, PASSWORD) + UserClient(EMAIL, credentials.apiKey, API_URL).deleteAccount(PASSWORD) + } } private lateinit var secretKey: ByteArray diff --git a/client/src/test/kotlin/dev/medzik/librepass/client/api/CollectionClientTests.kt b/client/src/test/kotlin/dev/medzik/librepass/client/api/CollectionClientTests.kt index 6ab5b515..9ab603d8 100644 --- a/client/src/test/kotlin/dev/medzik/librepass/client/api/CollectionClientTests.kt +++ b/client/src/test/kotlin/dev/medzik/librepass/client/api/CollectionClientTests.kt @@ -1,5 +1,6 @@ package dev.medzik.librepass.client.api +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.BeforeAll @@ -21,6 +22,14 @@ class CollectionClientTests { // wait for 1 second to prevent unauthorized error Thread.sleep(1000) } + + @AfterAll + @JvmStatic + fun delete() { + val authClient = AuthClient(API_URL) + val credentials = authClient.login(EMAIL, PASSWORD) + UserClient(EMAIL, credentials.apiKey, API_URL).deleteAccount(PASSWORD) + } } @BeforeEach diff --git a/client/src/test/kotlin/dev/medzik/librepass/client/api/TwoFactorTests.kt b/client/src/test/kotlin/dev/medzik/librepass/client/api/TwoFactorTests.kt index 90292a4f..5aca35e1 100644 --- a/client/src/test/kotlin/dev/medzik/librepass/client/api/TwoFactorTests.kt +++ b/client/src/test/kotlin/dev/medzik/librepass/client/api/TwoFactorTests.kt @@ -1,6 +1,7 @@ package dev.medzik.librepass.client.api import dev.medzik.librepass.utils.TOTP +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test @@ -26,6 +27,17 @@ class TwoFactorTests { val code = TOTP.getTOTPCode(twoFactorSecret) UserClient(EMAIL, auth.apiKey, API_URL).setupTwoFactor(PASSWORD, twoFactorSecret, code) } + + @AfterAll + @JvmStatic + fun delete() { + val credentials = authClient.login(EMAIL, PASSWORD) + + val code = TOTP.getTOTPCode(twoFactorSecret) + authClient.loginTwoFactor(credentials.apiKey, code) + + UserClient(EMAIL, credentials.apiKey, API_URL).deleteAccount(PASSWORD, code) + } } @Test diff --git a/client/src/test/kotlin/dev/medzik/librepass/client/api/UserClientTests.kt b/client/src/test/kotlin/dev/medzik/librepass/client/api/UserClientTests.kt index a3ab330c..ccefeea8 100644 --- a/client/src/test/kotlin/dev/medzik/librepass/client/api/UserClientTests.kt +++ b/client/src/test/kotlin/dev/medzik/librepass/client/api/UserClientTests.kt @@ -5,6 +5,7 @@ import dev.medzik.librepass.types.cipher.CipherType import dev.medzik.librepass.types.cipher.EncryptedCipher import dev.medzik.librepass.types.cipher.data.CipherLoginData import dev.medzik.librepass.utils.fromHexString +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach @@ -24,6 +25,14 @@ class UserClientTests { // wait for 1 second to prevent unauthorized error Thread.sleep(1000) } + + @AfterAll + @JvmStatic + fun delete() { + val authClient = AuthClient(API_URL) + val credentials = authClient.login(EMAIL, PASSWORD) + UserClient(EMAIL, credentials.apiKey, API_URL).deleteAccount(PASSWORD) + } } private lateinit var userId: UUID diff --git a/server/src/main/kotlin/dev/medzik/librepass/server/controllers/api/User.kt b/server/src/main/kotlin/dev/medzik/librepass/server/controllers/api/User.kt index 408630de..49a27e54 100644 --- a/server/src/main/kotlin/dev/medzik/librepass/server/controllers/api/User.kt +++ b/server/src/main/kotlin/dev/medzik/librepass/server/controllers/api/User.kt @@ -4,6 +4,7 @@ import dev.medzik.librepass.responses.ResponseError import dev.medzik.librepass.server.components.AuthorizedUser import dev.medzik.librepass.server.controllers.advice.InvalidTwoFactorCodeException import dev.medzik.librepass.server.database.CipherRepository +import dev.medzik.librepass.server.database.TokenRepository import dev.medzik.librepass.server.database.UserRepository import dev.medzik.librepass.server.database.UserTable import dev.medzik.librepass.server.utils.Response @@ -11,6 +12,7 @@ import dev.medzik.librepass.server.utils.ResponseHandler import dev.medzik.librepass.server.utils.Validator.validateSharedKey import dev.medzik.librepass.server.utils.toResponse import dev.medzik.librepass.types.api.ChangePasswordRequest +import dev.medzik.librepass.types.api.DeleteAccountRequest import dev.medzik.librepass.types.api.SetupTwoFactorRequest import dev.medzik.librepass.types.api.SetupTwoFactorResponse import dev.medzik.librepass.utils.TOTP @@ -25,6 +27,7 @@ class UserController @Autowired constructor( private val userRepository: UserRepository, + private val tokenRepository: TokenRepository, private val cipherRepository: CipherRepository ) { @PatchMapping("/password") @@ -100,4 +103,22 @@ class UserController val response = SetupTwoFactorResponse(recoveryCode = recoveryCode) return ResponseHandler.generateResponse(response, HttpStatus.OK) } + + @DeleteMapping("/delete") + fun deleteAccount( + @AuthorizedUser user: UserTable, + @RequestBody body: DeleteAccountRequest + ): Response { + if (!validateSharedKey(user, body.sharedKey)) + return ResponseError.INVALID_CREDENTIALS.toResponse() + + if (user.twoFactorEnabled && body.code != TOTP.getTOTPCode(user.twoFactorSecret!!)) + throw InvalidTwoFactorCodeException() + + tokenRepository.deleteAllByOwner(user.id) + cipherRepository.deleteAllByOwner(user.id) + userRepository.delete(user) + + return ResponseHandler.generateResponse(HttpStatus.OK) + } } diff --git a/server/src/main/kotlin/dev/medzik/librepass/server/database/CipherRepository.kt b/server/src/main/kotlin/dev/medzik/librepass/server/database/CipherRepository.kt index c51e4436..e5c4c9c8 100644 --- a/server/src/main/kotlin/dev/medzik/librepass/server/database/CipherRepository.kt +++ b/server/src/main/kotlin/dev/medzik/librepass/server/database/CipherRepository.kt @@ -56,4 +56,9 @@ interface CipherRepository : CrudRepository { @Param("id") id: UUID, @Param("data") data: String ) + + /** Remove all tokens owned by the user */ + @Transactional + @Modifying + fun deleteAllByOwner(owner: UUID) } diff --git a/server/src/main/kotlin/dev/medzik/librepass/server/database/TokenRepository.kt b/server/src/main/kotlin/dev/medzik/librepass/server/database/TokenRepository.kt index a6c6619c..839db69a 100644 --- a/server/src/main/kotlin/dev/medzik/librepass/server/database/TokenRepository.kt +++ b/server/src/main/kotlin/dev/medzik/librepass/server/database/TokenRepository.kt @@ -13,4 +13,9 @@ interface TokenRepository : CrudRepository { @Modifying @Query("DELETE FROM #{#entityName} t WHERE t.lastUsed < :lastUsedBefore") fun deleteUnused(lastUsedBefore: Date) + + /** Remove all tokens owned by the user */ + @Transactional + @Modifying + fun deleteAllByOwner(owner: UUID) } diff --git a/shared/src/main/kotlin/dev/medzik/librepass/types/api/User.kt b/shared/src/main/kotlin/dev/medzik/librepass/types/api/User.kt index 612d226e..d7a53b16 100644 --- a/shared/src/main/kotlin/dev/medzik/librepass/types/api/User.kt +++ b/shared/src/main/kotlin/dev/medzik/librepass/types/api/User.kt @@ -49,6 +49,17 @@ data class SetupTwoFactorRequest( val code: String ) +/** + * Request for endpoint that setups two-factor authentication. + * + * @property sharedKey The shared key with server, to verify authentication. + * @property code The OTP code (if 2fa is set) + */ +data class DeleteAccountRequest( + val sharedKey: String, + val code: String? +) + /** * Response from endpoint that setups two-factor authentication. *