Skip to content

Commit

Permalink
DOKY-179 Create a temporary token to download file
Browse files Browse the repository at this point in the history
Add JWT-based document download token generation

Implemented functionality to generate and manage JWT tokens for authorizing document downloads. Added new endpoints, service methods, database entities, and repository support for this feature. Updated migrations to include download token handling in both MySQL and SQL Server schemas.
  • Loading branch information
hanna-eismant committed Jan 14, 2025
1 parent 536852a commit 4e31e03
Show file tree
Hide file tree
Showing 14 changed files with 235 additions and 24 deletions.
2 changes: 1 addition & 1 deletion app-server/doky-front/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ module.exports = (env, argv) => {
},
devServer: {
historyApiFallback: true,
port: 10001
port: 10010
},
resolve: {
alias: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,12 @@ interface DocumentFacade {
*/
@Throws(IOException::class)
fun getFile(id: String): Resource?

/**
* Generates a download token for the specified document ID and current user.
*
* @param id The ID of the document for which the download token is being generated.
* @return A string representing the generated download token.
*/
fun generateDownloadToken(id: String): String
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.hkurh.doky.documents

import org.hkurh.doky.documents.db.DocumentEntity
import org.hkurh.doky.users.db.UserEntity

/**
* Represents a service for managing documents.
Expand Down Expand Up @@ -36,4 +37,13 @@ interface DocumentService {
* @param document The [DocumentEntity] object to be saved.
*/
fun save(document: DocumentEntity)

/**
* Generates a download token for a specified document and user.
*
* @param user The [UserEntity] representing the user requesting the download token.
* @param document The [DocumentEntity] representing the document for which the token is generated.
* @return A string representing the generated download token.
*/
fun generateDownloadToken(user: UserEntity, document: DocumentEntity): String
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,30 @@ import java.io.IOException
interface DocumentApi {
@Operation(summary = "Upload file to document entry")
@ApiResponses(
ApiResponse(responseCode = "200", description = "File is uploaded and attached to document"),
ApiResponse(responseCode = "404", description = "Document with provided id does not exist"))
ApiResponse(responseCode = "200", description = "File is uploaded and attached to document"),
ApiResponse(responseCode = "404", description = "Document with provided id does not exist")
)
fun uploadFile(@RequestBody file: MultipartFile, @PathVariable id: String): ResponseEntity<*>?

@Operation(summary = "Get token to authorize file download")
@ApiResponses(
ApiResponse(responseCode = "200", description = "Token is generated and provided"),
ApiResponse(responseCode = "404", description = "Document with provided id does not exist")
)
fun getDownloadToken(@PathVariable id: String): ResponseEntity<DownloadTokenResponse>?

@Operation(summary = "Download file attached to document entry")
@ApiResponses(
ApiResponse(responseCode = "200", description = "File is sent to client and can be downloaded",
content = [Content(mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE)],
headers = [Header(name = "attachment; filename=...")]),
ApiResponse(responseCode = "404", description = "No document with requested id, or no attached file for document"))
ApiResponse(
responseCode = "200", description = "File is sent to client and can be downloaded",
content = [Content(mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE)],
headers = [Header(name = "attachment; filename=...")]
),
ApiResponse(
responseCode = "404",
description = "No document with requested id, or no attached file for document"
)
)
@Throws(IOException::class)
fun downloadFile(@PathVariable id: String): ResponseEntity<*>?

Expand All @@ -47,9 +61,12 @@ interface DocumentApi {

@Operation(summary = "Get metadata for document")
@ApiResponses(
ApiResponse(responseCode = "200", description = "Document information is retrieved successfully",
content = [Content(schema = Schema(implementation = DocumentResponse::class))]),
ApiResponse(responseCode = "404", description = "No document with provided id"))
ApiResponse(
responseCode = "200", description = "Document information is retrieved successfully",
content = [Content(schema = Schema(implementation = DocumentResponse::class))]
),
ApiResponse(responseCode = "404", description = "No document with provided id")
)
operator fun get(@PathVariable id: String): ResponseEntity<*>?

@ApiResponses(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ class DocumentController(private val documentFacade: DocumentFacade) : DocumentA
return ResponseEntity.ok<Any>(null)
}

@GetMapping("/{id}/download/token")
override fun getDownloadToken(@PathVariable id: String): ResponseEntity<DownloadTokenResponse>? {
val token = documentFacade.generateDownloadToken(id)
return ResponseEntity.ok(DownloadTokenResponse(token))
}

@GetMapping("/{id}/download")
@Trace(operationName = "document.download")
@Throws(IOException::class)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.hkurh.doky.documents.api

/**
* Represents the response containing a token used for authorizing a file download.
*
* @property token The token string used to authorize the download operation.
*/
class DownloadTokenResponse(val token: String = "")
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import org.hkurh.doky.documents.api.DocumentResponse
import org.hkurh.doky.errorhandling.DokyNotFoundException
import org.hkurh.doky.filestorage.FileStorageService
import org.hkurh.doky.toDto
import org.hkurh.doky.users.UserService
import org.springframework.core.io.Resource
import org.springframework.core.io.UrlResource
import org.springframework.stereotype.Component
Expand All @@ -20,7 +21,8 @@ import java.io.IOException
@Component
class DefaultDocumentFacade(
private val documentService: DocumentService,
private val fileStorageService: FileStorageService
private val fileStorageService: FileStorageService,
private val userService: UserService
) : DocumentFacade {
override fun createDocument(name: String, description: String?): DocumentResponse? {
val documentEntity = documentService.create(name, description)
Expand Down Expand Up @@ -48,18 +50,14 @@ class DefaultDocumentFacade(

@Transactional(propagation = Propagation.REQUIRED)
override fun saveFile(file: MultipartFile, id: String) {
val document = documentService.find(id)
if (document != null) {
try {
val path = fileStorageService.store(file, document.filePath)
document.filePath = path
document.fileName = file.originalFilename
documentService.save(document)
} catch (e: IOException) {
throw RuntimeException(e)
}
} else {
throw DokyNotFoundException("Document with id [$id] not found")
val document = documentService.find(id) ?: throw DokyNotFoundException("Document with id [$id] not found")
try {
val path = fileStorageService.store(file, document.filePath)
document.filePath = path
document.fileName = file.originalFilename
documentService.save(document)
} catch (e: IOException) {
throw RuntimeException(e)
}
}

Expand All @@ -83,6 +81,14 @@ class DefaultDocumentFacade(
}
}

override fun generateDownloadToken(id: String): String {
val user = userService.getCurrentUser()
val document = documentService.find(id) ?: throw DokyNotFoundException("Document with id [$id] not found")
LOG.debug { "Generate token for user [${user.id}] and document [$id]" }
val token = documentService.generateDownloadToken(user, document)
return token
}

companion object {
private val LOG = KotlinLogging.logger {}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.hkurh.doky.documents.DocumentService
import org.hkurh.doky.documents.db.DocumentEntity
import org.hkurh.doky.documents.db.DocumentEntityRepository
import org.hkurh.doky.documents.db.DownloadDocumentTokenEntity
import org.hkurh.doky.documents.db.DownloadDocumentTokenEntityRepository
import org.hkurh.doky.security.JwtProvider
import org.hkurh.doky.users.UserService
import org.hkurh.doky.users.db.UserEntity
import org.springframework.stereotype.Service

@Service
class DefaultDocumentService(
private val documentEntityRepository: DocumentEntityRepository,
private val downloadDocumentTokenEntityRepository: DownloadDocumentTokenEntityRepository,
private val userService: UserService,
private val jwtProvider: JwtProvider
) : DocumentService {

override fun create(name: String, description: String?): DocumentEntity {
Expand Down Expand Up @@ -39,6 +45,19 @@ class DefaultDocumentService(
documentEntityRepository.save(document)
}

override fun generateDownloadToken(user: UserEntity, document: DocumentEntity): String {
val token = jwtProvider.generateDownloadToken(user)
val tokenEntity =
downloadDocumentTokenEntityRepository.findByUserAndDocument(user, document) ?: DownloadDocumentTokenEntity()
tokenEntity.apply {
this.user = user
this.document = document
this.token = token
}
downloadDocumentTokenEntityRepository.save(tokenEntity)
return token
}

companion object {
private val LOG = KotlinLogging.logger {}
}
Expand Down
22 changes: 22 additions & 0 deletions app-server/src/main/kotlin/org/hkurh/doky/security/JwtProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.hkurh.doky.security
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import org.hkurh.doky.DokyApplication
import org.hkurh.doky.users.db.UserEntity
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.springframework.stereotype.Component
Expand Down Expand Up @@ -36,6 +37,27 @@ object JwtProvider {
.compact()
}

/**
* Generates a download token for a given user and document. The token is a JWT
* with an expiration time of 10 minutes and includes claims containing the user ID
* and document ID.
*
* @param user the user for whom the token is being generated
* @return a JWT download token as a string
*/
fun generateDownloadToken(user: UserEntity): String {
val currentTime = DateTime(DateTimeZone.getDefault())
val expireTokenTime = currentTime.plusMinutes(10)
val claims = mapOf("user" to user.id)
return Jwts.builder()
.setId("dokyDownloadToken")
.setClaims(claims)
.setIssuedAt(currentTime.toDate())
.setExpiration(expireTokenTime.toDate())
.signWith(DokyApplication.SECRET_KEY_SPEC, SignatureAlgorithm.HS256)
.compact()
}

/**
* Retrieves the username from the provided token.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import org.hkurh.doky.documents.api.DocumentRequest
import org.hkurh.doky.documents.db.DocumentEntity
import org.hkurh.doky.errorhandling.DokyNotFoundException
import org.hkurh.doky.filestorage.FileStorageService
import org.hkurh.doky.users.UserService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
Expand All @@ -23,7 +24,8 @@ class DefaultDocumentFacadeTest : DokyUnitTest {

private var documentService: DocumentService = mock()
private var fileStorageService: FileStorageService = mock()
private var documentFacade = DefaultDocumentFacade(documentService, fileStorageService)
private var userService: UserService = mock()
private var documentFacade = DefaultDocumentFacade(documentService, fileStorageService, userService)

@BeforeEach
fun setUp() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.hkurh.doky.documents.db

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Index
import jakarta.persistence.JoinColumn
import jakarta.persistence.OneToOne
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import org.hkurh.doky.users.db.UserEntity

@Entity
@Table(
name = "download_document_tokens",
indexes = [Index(name = "idx_download_document_tokens_token", columnList = "token")],
uniqueConstraints = [
UniqueConstraint(name = "uc_download_document_tokens_token", columnNames = ["token"]),
UniqueConstraint(
name = "uc_download_document_tokens_app_user_document",
columnNames = ["app_user", "document"]
)]
)
class DownloadDocumentTokenEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false, updatable = false)
var id: Long? = null

@OneToOne
@JoinColumn(name = "app_user", nullable = false, unique = true)
lateinit var user: UserEntity

@OneToOne
@JoinColumn(name = "document", nullable = false, unique = true)
lateinit var document: DocumentEntity

@Column(name = "token", nullable = false, unique = true)
lateinit var token: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.hkurh.doky.documents.db

import org.hkurh.doky.users.db.UserEntity
import org.springframework.data.jpa.repository.JpaSpecificationExecutor
import org.springframework.data.repository.CrudRepository


/**
* Repository interface for managing [DownloadDocumentTokenEntity] objects.
* Provides methods for querying and persisting [DownloadDocumentTokenEntity] instances in the database.
* Inherits functionality from [CrudRepository] for common CRUD operations and [JpaSpecificationExecutor]
* for specification-based queries.
*/
interface DownloadDocumentTokenEntityRepository : CrudRepository<DownloadDocumentTokenEntity?, Long?>,
JpaSpecificationExecutor<DownloadDocumentTokenEntity?> {

fun findByUserAndDocument(user: UserEntity, document: DocumentEntity): DownloadDocumentTokenEntity?

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
CREATE TABLE download_document_tokens
(
id BIGINT AUTO_INCREMENT NOT NULL,
app_user BIGINT NOT NULL,
document BIGINT NOT NULL,
token VARCHAR(255) NOT NULL,
CONSTRAINT pk_download_document_tokens PRIMARY KEY (id)
);

ALTER TABLE download_document_tokens
ADD CONSTRAINT uc_download_document_tokens_app_user_document UNIQUE (app_user, document);

ALTER TABLE download_document_tokens
ADD CONSTRAINT uc_download_document_tokens_token UNIQUE (token);

CREATE INDEX idx_download_document_tokens_token ON download_document_tokens (token);

ALTER TABLE download_document_tokens
ADD CONSTRAINT FK_DOWNLOAD_DOCUMENT_TOKENS_ON_APP_USER FOREIGN KEY (app_user) REFERENCES users (id);

ALTER TABLE download_document_tokens
ADD CONSTRAINT FK_DOWNLOAD_DOCUMENT_TOKENS_ON_DOCUMENT FOREIGN KEY (document) REFERENCES documents (id);

Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
CREATE TABLE download_document_tokens
(
id bigint IDENTITY (1, 1) NOT NULL,
app_user bigint NOT NULL,
document bigint NOT NULL,
token varchar(255) NOT NULL,
CONSTRAINT pk_download_document_tokens PRIMARY KEY (id)
)
GO

ALTER TABLE download_document_tokens
ADD CONSTRAINT uc_download_document_tokens_app_user_document UNIQUE (app_user, document)
GO

ALTER TABLE download_document_tokens
ADD CONSTRAINT uc_download_document_tokens_token UNIQUE (token)
GO

CREATE NONCLUSTERED INDEX idx_download_document_tokens_token ON download_document_tokens (token)
GO

ALTER TABLE download_document_tokens
ADD CONSTRAINT FK_DOWNLOAD_DOCUMENT_TOKENS_ON_APP_USER FOREIGN KEY (app_user) REFERENCES users (id)
GO

ALTER TABLE download_document_tokens
ADD CONSTRAINT FK_DOWNLOAD_DOCUMENT_TOKENS_ON_DOCUMENT FOREIGN KEY (document) REFERENCES documents (id)
GO

0 comments on commit 4e31e03

Please sign in to comment.