From 3ab5533ac51b8efb862f8a091d7d067187b62bb7 Mon Sep 17 00:00:00 2001 From: Hanna Kurhuzenkava Date: Sun, 28 Apr 2024 00:11:13 +0300 Subject: [PATCH] MAIN-T-5 Add roles for users Implement role-based authentication Updated the users, documents, and tests to switch from secured authorization to the pre-authorized method. Adjusted SQL statement procedures, roles structure and handling of user roles. Now user roles are stored in database. Unit tests were modified accordingly. Also, removed files and code blocks related to the deprecated security method. --- .../resources/sql/DocumentSpec/cleanup.sql | 2 + .../resources/sql/DocumentSpec/setup.sql | 15 ++++-- .../sql/RegistrationSpec/cleanup.sql | 2 + .../resources/sql/SearchSpec/cleanup.sql | 2 + .../resources/sql/SearchSpec/setup.sql | 15 ++++-- .../resources/sql/cleanup_base_test_data.sql | 2 + .../resources/sql/create_base_test_data.sql | 7 ++- ...aultDocumentIndexServiceIntegrationTest.kt | 4 +- .../cleanup.sql | 2 + .../setup.sql | 10 ++-- .../resources/sql/cleanup_base_test_data.sql | 2 + .../resources/sql/create_base_test_data.sql | 7 ++- .../main/kotlin/org/hkurh/doky/DokyMapper.kt | 9 ++-- .../AuthorizationUserController.kt | 6 +-- .../doky/documents/api/DocumentController.kt | 5 +- .../password/impl/DefaultPasswordFacade.kt | 2 +- .../search/api/DocumentSearchController.kt | 5 +- .../org/hkurh/doky/security/DokyAuthority.kt | 15 ------ .../hkurh/doky/security/DokyUserDetails.kt | 28 ++++++----- .../doky/security/JwtAuthorizationFilter.kt | 46 +++++++++++-------- .../org/hkurh/doky/security/JwtProvider.kt | 20 ++++---- .../org/hkurh/doky/security/UserAuthority.kt | 6 +++ .../hkurh/doky/security/WebSecurityConfig.kt | 2 +- .../kotlin/org/hkurh/doky/users/UserFacade.kt | 2 +- .../hkurh/doky/users/api/UserController.kt | 5 +- .../org/hkurh/doky/users/api/UserDto.kt | 18 +++++--- .../hkurh/doky/users/db/AuthorityEntity.kt | 29 ++++++++++++ .../org/hkurh/doky/users/db/UserEntity.kt | 12 +++++ .../doky/users/impl/DefaultUserFacade.kt | 3 +- .../V16_0__add_user_roles_structure.sql | 24 ++++++++++ .../V16_1__add_roles_to_existing_users.sql | 7 +++ ...MigrateError__remove_failed_migrations.sql | 1 + .../AuthorizationUserControllerTest.kt | 14 +++++- .../security/JwtAuthorizationFilterTest.kt | 18 ++++++-- 34 files changed, 239 insertions(+), 108 deletions(-) delete mode 100644 server/src/main/kotlin/org/hkurh/doky/security/DokyAuthority.kt create mode 100644 server/src/main/kotlin/org/hkurh/doky/security/UserAuthority.kt create mode 100644 server/src/main/kotlin/org/hkurh/doky/users/db/AuthorityEntity.kt create mode 100644 server/src/main/resources/db/migration/V16_0__add_user_roles_structure.sql create mode 100644 server/src/main/resources/db/migration/V16_1__add_roles_to_existing_users.sql create mode 100644 server/src/main/resources/db/migration/afterMigrateError__remove_failed_migrations.sql diff --git a/server/src/apiTest/resources/sql/DocumentSpec/cleanup.sql b/server/src/apiTest/resources/sql/DocumentSpec/cleanup.sql index 8b9a7c32..89321525 100644 --- a/server/src/apiTest/resources/sql/DocumentSpec/cleanup.sql +++ b/server/src/apiTest/resources/sql/DocumentSpec/cleanup.sql @@ -1,3 +1,5 @@ delete d from document d inner join user u on d.creator_id = u.id where u.uid = "hanna_test_2@example.com"; +delete a from user_authorities a inner join user u on a.user_id = u.id where u.uid = "hanna_test_2@example.com"; + delete u from user u where u.uid = "hanna_test_2@example.com"; diff --git a/server/src/apiTest/resources/sql/DocumentSpec/setup.sql b/server/src/apiTest/resources/sql/DocumentSpec/setup.sql index dc36defd..14971caa 100644 --- a/server/src/apiTest/resources/sql/DocumentSpec/setup.sql +++ b/server/src/apiTest/resources/sql/DocumentSpec/setup.sql @@ -1,13 +1,18 @@ -replace into document (name, description, file_name, created_date, modified_date, creator_id ) +INSERT IGNORE INTO document (name, description, file_name, created_date, modified_date, creator_id ) select "Test_1", "That is a test document", "test.txt", '2024-01-15 13:00:00', '2024-01-15 13:00:00', u.id from user u where u.uid = "hanna_test_1@example.com"; -replace into document (name, description, created_date, modified_date, creator_id ) +INSERT IGNORE INTO document (name, description, created_date, modified_date, creator_id ) select "Test_2", "That is a second test document", '2024-01-15 16:00:00', '2024-01-15 16:00:00', u.id from user u where u.uid = "hanna_test_1@example.com"; -replace into user (uid, name, password) values ( "hanna_test_2@example.com", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" ); +INSERT IGNORE INTO user (uid, name, password) values ( "hanna_test_2@example.com", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" ); -replace into document (name, description, created_date, modified_date, creator_id ) +INSERT IGNORE INTO user_authorities (user_id, authority_id) +SELECT u.id, a.id +FROM user u, authorities a +WHERE a.authority = 'ROLE_USER' AND u.uid = "hanna_test_2@example.com"; + +INSERT IGNORE INTO document (name, description, created_date, modified_date, creator_id ) select "Test_3", "That is a test document", '2024-02-13 13:00:00', '2024-03-15 13:24:00', u.id from user u where u.uid = "hanna_test_2@example.com"; -replace into document (name, description, created_date, modified_date, creator_id ) +INSERT IGNORE INTO document (name, description, created_date, modified_date, creator_id ) select "Test_4", "That is a second test document", '2024-01-15 13:00:00', '2024-01-15 13:00:00', u.id from user u where u.uid = "hanna_test_2@example.com"; diff --git a/server/src/apiTest/resources/sql/RegistrationSpec/cleanup.sql b/server/src/apiTest/resources/sql/RegistrationSpec/cleanup.sql index 482b73de..17e7c47d 100644 --- a/server/src/apiTest/resources/sql/RegistrationSpec/cleanup.sql +++ b/server/src/apiTest/resources/sql/RegistrationSpec/cleanup.sql @@ -1 +1,3 @@ delete u from user u where u.uid = "new_user_test@example.com"; + +delete a from user_authorities a inner join user u on a.user_id = u.id where u.uid = "new_user_test@example.com"; diff --git a/server/src/apiTest/resources/sql/SearchSpec/cleanup.sql b/server/src/apiTest/resources/sql/SearchSpec/cleanup.sql index 8b9a7c32..89321525 100644 --- a/server/src/apiTest/resources/sql/SearchSpec/cleanup.sql +++ b/server/src/apiTest/resources/sql/SearchSpec/cleanup.sql @@ -1,3 +1,5 @@ delete d from document d inner join user u on d.creator_id = u.id where u.uid = "hanna_test_2@example.com"; +delete a from user_authorities a inner join user u on a.user_id = u.id where u.uid = "hanna_test_2@example.com"; + delete u from user u where u.uid = "hanna_test_2@example.com"; diff --git a/server/src/apiTest/resources/sql/SearchSpec/setup.sql b/server/src/apiTest/resources/sql/SearchSpec/setup.sql index a8e98b06..124db2d6 100644 --- a/server/src/apiTest/resources/sql/SearchSpec/setup.sql +++ b/server/src/apiTest/resources/sql/SearchSpec/setup.sql @@ -1,13 +1,18 @@ -replace into document (name, description, file_name, created_date, modified_date, creator_id ) +INSERT IGNORE INTO document (name, description, file_name, created_date, modified_date, creator_id ) select "Test note 1", "That is a test document", "test.txt", '2024-01-15 13:00:00', '2024-01-15 13:00:00', u.id from user u where u.uid = "hanna_test_1@example.com"; -replace into document (name, description, created_date, modified_date, creator_id ) +INSERT IGNORE INTO document (name, description, created_date, modified_date, creator_id ) select "Lorem", "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed vitae augue et tortor dapibus finibus.", '2024-01-15 16:00:00', '2024-01-15 16:00:00', u.id from user u where u.uid = "hanna_test_1@example.com"; -replace into user (uid, name, password) values ( "hanna_test_2@example.com", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" ); +INSERT IGNORE INTO user (uid, name, password) values ( "hanna_test_2@example.com", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" ); -replace into document (name, description, created_date, modified_date, creator_id ) +INSERT IGNORE INTO user_authorities (user_id, authority_id) +SELECT u.id, a.id +FROM user u, authorities a +WHERE a.authority = 'ROLE_USER' AND u.uid = "hanna_test_2@example.com"; + +INSERT IGNORE INTO document (name, description, created_date, modified_date, creator_id ) select "Test note 2", "That is a test document", '2024-02-13 13:00:00', '2024-03-15 13:24:00', u.id from user u where u.uid = "hanna_test_2@example.com"; -replace into document (name, description, created_date, modified_date, creator_id ) +INSERT IGNORE INTO document (name, description, created_date, modified_date, creator_id ) select "Cras at nulla ex", "Phasellus vestibulum nisl augue, a pharetra nunc molestie ut. Integer mollis ex fringilla vulputate facilisis.", '2024-01-15 13:00:00', '2024-01-15 13:00:00', u.id from user u where u.uid = "hanna_test_2@example.com"; diff --git a/server/src/apiTest/resources/sql/cleanup_base_test_data.sql b/server/src/apiTest/resources/sql/cleanup_base_test_data.sql index 4114c30b..18905023 100644 --- a/server/src/apiTest/resources/sql/cleanup_base_test_data.sql +++ b/server/src/apiTest/resources/sql/cleanup_base_test_data.sql @@ -2,4 +2,6 @@ delete d from document d inner join user u on d.creator_id = u.id where u.uid = delete from reset_password_token; +delete a from user_authorities a inner join user u on a.user_id = u.id where u.uid = "hanna_test_1@example.com"; + delete u from user u where u.uid = "hanna_test_1@example.com"; diff --git a/server/src/apiTest/resources/sql/create_base_test_data.sql b/server/src/apiTest/resources/sql/create_base_test_data.sql index 3e85aaff..578ac325 100644 --- a/server/src/apiTest/resources/sql/create_base_test_data.sql +++ b/server/src/apiTest/resources/sql/create_base_test_data.sql @@ -1 +1,6 @@ -replace into user (uid, name, password) values ( "hanna_test_1@example.com", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" ); +INSERT IGNORE INTO user (uid, name, password) values ( "hanna_test_1@example.com", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" ); + +INSERT IGNORE INTO user_authorities (user_id, authority_id) +SELECT u.id, a.id +FROM user u, authorities a +WHERE a.authority = 'ROLE_USER' AND u.uid = "hanna_test_1@example.com"; diff --git a/server/src/integrationTest/kotlin/org/hkurh/doky/search/DefaultDocumentIndexServiceIntegrationTest.kt b/server/src/integrationTest/kotlin/org/hkurh/doky/search/DefaultDocumentIndexServiceIntegrationTest.kt index 92400c10..199067e1 100644 --- a/server/src/integrationTest/kotlin/org/hkurh/doky/search/DefaultDocumentIndexServiceIntegrationTest.kt +++ b/server/src/integrationTest/kotlin/org/hkurh/doky/search/DefaultDocumentIndexServiceIntegrationTest.kt @@ -4,7 +4,7 @@ import org.apache.solr.client.solrj.SolrClient import org.apache.solr.common.params.CommonParams import org.apache.solr.common.params.ModifiableSolrParams import org.hkurh.doky.DokyIntegrationTest -import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -47,6 +47,6 @@ class DefaultDocumentIndexServiceIntegrationTest : DokyIntegrationTest() { // then val solrParams = ModifiableSolrParams() solrParams.add(CommonParams.Q, "*:*") - assertTrue(solrClient.query(coreName, solrParams).results.size == 4) + assertEquals(4, solrClient.query(coreName, solrParams).results.size) } } diff --git a/server/src/integrationTest/resources/sql/DocumentIndexServiceIntegrationTest/cleanup.sql b/server/src/integrationTest/resources/sql/DocumentIndexServiceIntegrationTest/cleanup.sql index 8b9a7c32..89321525 100644 --- a/server/src/integrationTest/resources/sql/DocumentIndexServiceIntegrationTest/cleanup.sql +++ b/server/src/integrationTest/resources/sql/DocumentIndexServiceIntegrationTest/cleanup.sql @@ -1,3 +1,5 @@ delete d from document d inner join user u on d.creator_id = u.id where u.uid = "hanna_test_2@example.com"; +delete a from user_authorities a inner join user u on a.user_id = u.id where u.uid = "hanna_test_2@example.com"; + delete u from user u where u.uid = "hanna_test_2@example.com"; diff --git a/server/src/integrationTest/resources/sql/DocumentIndexServiceIntegrationTest/setup.sql b/server/src/integrationTest/resources/sql/DocumentIndexServiceIntegrationTest/setup.sql index 463e53ec..ea6b4a61 100644 --- a/server/src/integrationTest/resources/sql/DocumentIndexServiceIntegrationTest/setup.sql +++ b/server/src/integrationTest/resources/sql/DocumentIndexServiceIntegrationTest/setup.sql @@ -1,13 +1,13 @@ -insert into document (name, description, created_date, modified_date, creator_id ) +INSERT IGNORE INTO document (name, description, created_date, modified_date, creator_id ) select "Test_1", "That is a test document", '2024-01-15 13:00:00', '2024-01-15 13:00:00', u.id from user u where u.uid = "hanna_test_1@example.com"; -insert into document (name, description, created_date, modified_date, creator_id ) +INSERT IGNORE INTO document (name, description, created_date, modified_date, creator_id ) select "Test_2", "That is a second test document", '2024-01-15 16:00:00', '2024-01-15 16:00:00', u.id from user u where u.uid = "hanna_test_1@example.com"; -insert into user (uid, name, password) values ( "hanna_test_2@example.com", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" ); +INSERT IGNORE INTO user (uid, name, password) values ( "hanna_test_2@example.com", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" ); -insert into document (name, description, created_date, modified_date, creator_id ) +INSERT IGNORE INTO document (name, description, created_date, modified_date, creator_id ) select "Test_3", "That is a test document", '2024-02-13 13:00:00', '2024-03-15 13:24:00', u.id from user u where u.uid = "hanna_test_2@example.com"; -insert into document (name, description, created_date, modified_date, creator_id ) +INSERT IGNORE INTO document (name, description, created_date, modified_date, creator_id ) select "Test_4", "That is a second test document", '2024-01-15 13:00:00', '2024-01-15 13:00:00', u.id from user u where u.uid = "hanna_test_2@example.com"; diff --git a/server/src/integrationTest/resources/sql/cleanup_base_test_data.sql b/server/src/integrationTest/resources/sql/cleanup_base_test_data.sql index 377996a5..537d6fff 100644 --- a/server/src/integrationTest/resources/sql/cleanup_base_test_data.sql +++ b/server/src/integrationTest/resources/sql/cleanup_base_test_data.sql @@ -1,3 +1,5 @@ delete d from document d inner join user u on d.creator_id = u.id where u.uid = "hanna_test_1@example.com"; +delete a from user_authorities a inner join user u on a.user_id = u.id where u.uid = "hanna_test_1@example.com"; + delete u from user u where u.uid = "hanna_test_1@example.com"; diff --git a/server/src/integrationTest/resources/sql/create_base_test_data.sql b/server/src/integrationTest/resources/sql/create_base_test_data.sql index 3399a97b..4f40db21 100644 --- a/server/src/integrationTest/resources/sql/create_base_test_data.sql +++ b/server/src/integrationTest/resources/sql/create_base_test_data.sql @@ -1 +1,6 @@ -insert into user (uid, name, password) values ( "hanna_test_1@example.com", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" ); +INSERT IGNORE INTO user (uid, name, password) values ( "hanna_test_1@example.com", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" ); + +INSERT IGNORE INTO user_authorities (user_id, authority_id) +SELECT u.id, a.id +FROM user u, authorities a +WHERE a.authority = 'ROLE_USER' AND u.uid = "hanna_test_1@example.com" diff --git a/server/src/main/kotlin/org/hkurh/doky/DokyMapper.kt b/server/src/main/kotlin/org/hkurh/doky/DokyMapper.kt index bfcab9d7..9fea8bc1 100644 --- a/server/src/main/kotlin/org/hkurh/doky/DokyMapper.kt +++ b/server/src/main/kotlin/org/hkurh/doky/DokyMapper.kt @@ -11,11 +11,12 @@ import java.util.* fun UserEntity.toDto(): UserDto { val entity = this - return UserDto( - id = entity.id, - uid = entity.uid, + return UserDto().apply { + id = entity.id + uid = entity.uid name = entity.name - ) + roles = entity.authorities.map { it.authority.name }.toMutableSet() + } } fun DocumentEntity.toDto(): DocumentResponse { diff --git a/server/src/main/kotlin/org/hkurh/doky/authorization/AuthorizationUserController.kt b/server/src/main/kotlin/org/hkurh/doky/authorization/AuthorizationUserController.kt index 7218fe8c..1436ce9f 100644 --- a/server/src/main/kotlin/org/hkurh/doky/authorization/AuthorizationUserController.kt +++ b/server/src/main/kotlin/org/hkurh/doky/authorization/AuthorizationUserController.kt @@ -20,8 +20,8 @@ class AuthorizationUserController(private val userFacade: UserFacade) : Authoriz override fun login(@Valid @RequestBody authenticationRequest: AuthenticationRequest): ResponseEntity { val username = authenticationRequest.uid val password = authenticationRequest.password - userFacade.checkCredentials(username, password) - val token = generateToken(username) + val user = userFacade.checkCredentials(username, password) + val token = generateToken(user.uid, user.roles) return ResponseEntity.ok(AuthenticationResponse(token)) } @@ -30,7 +30,7 @@ class AuthorizationUserController(private val userFacade: UserFacade) : Authoriz val registeredUser = userFacade.register(registrationRequest.uid, registrationRequest.password) val resourceLocation = ServletUriComponentsBuilder.fromCurrentRequest() .path("/{id}").build(registeredUser.id) - val token = generateToken(registeredUser.uid) + val token = generateToken(registeredUser.uid, registeredUser.roles) return ResponseEntity.created(resourceLocation).body(AuthenticationResponse(token)) } } diff --git a/server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentController.kt b/server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentController.kt index 4a4619a5..5796ca61 100644 --- a/server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentController.kt +++ b/server/src/main/kotlin/org/hkurh/doky/documents/api/DocumentController.kt @@ -2,12 +2,11 @@ package org.hkurh.doky.documents.api import jakarta.validation.Valid import org.hkurh.doky.documents.DocumentFacade -import org.hkurh.doky.security.DokyAuthority import org.slf4j.LoggerFactory import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.http.ResponseEntity -import org.springframework.security.access.annotation.Secured +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile import org.springframework.web.servlet.support.ServletUriComponentsBuilder @@ -19,7 +18,7 @@ import java.net.MalformedURLException */ @RestController @RequestMapping("/documents") -@Secured(DokyAuthority.Role.ROLE_USER) +@PreAuthorize("hasRole('ROLE_USER')") class DocumentController(private val documentFacade: DocumentFacade) : DocumentApi { @PostMapping("/{id}/upload") diff --git a/server/src/main/kotlin/org/hkurh/doky/password/impl/DefaultPasswordFacade.kt b/server/src/main/kotlin/org/hkurh/doky/password/impl/DefaultPasswordFacade.kt index 5513ef68..3c9c472f 100644 --- a/server/src/main/kotlin/org/hkurh/doky/password/impl/DefaultPasswordFacade.kt +++ b/server/src/main/kotlin/org/hkurh/doky/password/impl/DefaultPasswordFacade.kt @@ -20,7 +20,7 @@ class DefaultPasswordFacade( val user = userService.findUserByUid(email) val token = resetPasswordService.generateAndSaveResetToken(user!!) - LOG.debug("Generate reset password token for user ${user.id}") + LOG.debug("Generate reset password token for user [${user.id}]") try { emailService.sendRestorePasswordEmail(user, token) } catch (e: Exception) { diff --git a/server/src/main/kotlin/org/hkurh/doky/search/api/DocumentSearchController.kt b/server/src/main/kotlin/org/hkurh/doky/search/api/DocumentSearchController.kt index 9ac51b98..72224933 100644 --- a/server/src/main/kotlin/org/hkurh/doky/search/api/DocumentSearchController.kt +++ b/server/src/main/kotlin/org/hkurh/doky/search/api/DocumentSearchController.kt @@ -1,9 +1,8 @@ package org.hkurh.doky.search.api import org.hkurh.doky.search.DocumentSearchFacade -import org.hkurh.doky.security.DokyAuthority import org.springframework.http.ResponseEntity -import org.springframework.security.access.annotation.Secured +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -16,7 +15,7 @@ import org.springframework.web.bind.annotation.RestController */ @RestController @RequestMapping("/documents") -@Secured(DokyAuthority.Role.ROLE_USER) +@PreAuthorize("hasRole('ROLE_USER')") class DocumentSearchController( private val documentSearchFacade: DocumentSearchFacade ) : DocumentSearchApi { diff --git a/server/src/main/kotlin/org/hkurh/doky/security/DokyAuthority.kt b/server/src/main/kotlin/org/hkurh/doky/security/DokyAuthority.kt deleted file mode 100644 index 3d08457d..00000000 --- a/server/src/main/kotlin/org/hkurh/doky/security/DokyAuthority.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.hkurh.doky.security - -import org.springframework.security.core.GrantedAuthority - -enum class DokyAuthority(private val authority: String) : GrantedAuthority { - ROLE_USER(Role.ROLE_USER); - - override fun getAuthority(): String { - return authority - } - - object Role { - const val ROLE_USER = "ROLE_USER" - } -} diff --git a/server/src/main/kotlin/org/hkurh/doky/security/DokyUserDetails.kt b/server/src/main/kotlin/org/hkurh/doky/security/DokyUserDetails.kt index 746b15a0..3dd5f783 100644 --- a/server/src/main/kotlin/org/hkurh/doky/security/DokyUserDetails.kt +++ b/server/src/main/kotlin/org/hkurh/doky/security/DokyUserDetails.kt @@ -2,14 +2,29 @@ package org.hkurh.doky.security import org.hkurh.doky.users.db.UserEntity import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.UserDetails -class DokyUserDetails : UserDetails { +class DokyUserDetails() : UserDetails { private var userEntity: UserEntity? = null private var username: String = "" private var password: String = "" private var grantedAuthorities: Collection? = null + + constructor(userEntity: UserEntity) : this() { + setUserEntity(userEntity) + } + + private fun setUserEntity(userEntity: UserEntity?) { + this.userEntity = userEntity + userEntity?.let { user -> + this.username = user.uid + this.password = user.password + this.grantedAuthorities = user.authorities.map { SimpleGrantedAuthority(it.authority.name) } + } + } + override fun getAuthorities(): Collection = grantedAuthorities!! override fun getPassword(): String = password override fun getUsername(): String = username @@ -18,15 +33,4 @@ class DokyUserDetails : UserDetails { override fun isCredentialsNonExpired(): Boolean = true override fun isEnabled(): Boolean = true fun getUserEntity(): UserEntity? = userEntity - - companion object { - fun createUserDetails(userEntity: UserEntity): DokyUserDetails { - val dokyUserDetails = DokyUserDetails() - dokyUserDetails.userEntity = userEntity - dokyUserDetails.username = userEntity.uid - dokyUserDetails.password = userEntity.password - dokyUserDetails.grantedAuthorities = listOf(DokyAuthority.ROLE_USER) - return dokyUserDetails - } - } } diff --git a/server/src/main/kotlin/org/hkurh/doky/security/JwtAuthorizationFilter.kt b/server/src/main/kotlin/org/hkurh/doky/security/JwtAuthorizationFilter.kt index 76cfe023..5cb8eec0 100644 --- a/server/src/main/kotlin/org/hkurh/doky/security/JwtAuthorizationFilter.kt +++ b/server/src/main/kotlin/org/hkurh/doky/security/JwtAuthorizationFilter.kt @@ -31,7 +31,7 @@ import java.io.IOException @Component class JwtAuthorizationFilter(private val userService: UserService) : OncePerRequestFilter() { private val authorizationHeader = "Authorization" - private val anonymousEndpoints = arrayOf("/register", "/login", "/password/reset") + private val anonymousEndpoints = setOf("/register", "/login", "/password/reset") @Throws(ServletException::class, IOException::class) override fun doFilterInternal( @@ -48,30 +48,36 @@ class JwtAuthorizationFilter(private val userService: UserService) : OncePerRequ } try { - val token = getTokenFromRequest(request) - if (token != null) { - val usernameFromToken = getUsernameFromToken(token) - val currentUser = userService.findUserByUid(usernameFromToken) - val dokyUserDetails: DokyUserDetails = DokyUserDetails.createUserDetails(currentUser!!) - val authenticationToken = - UsernamePasswordAuthenticationToken(dokyUserDetails, null, dokyUserDetails.getAuthorities()) - SecurityContextHolder.getContext().authentication = authenticationToken + processAuthorization(request, filterChain, response) + } catch (e: Exception) { + if (e is JwtException || e is DokyNotFoundException) { + response.status = HttpServletResponse.SC_FORBIDDEN + response.sendError(HttpServletResponse.SC_FORBIDDEN, e.message) } else { - LOG.warn("Token is not presented. Clear authorization context.") - SecurityContextHolder.clearContext() + throw e } - filterChain.doFilter(request, response) - } catch (e: Exception) { - when (e) { - is JwtException, - is DokyNotFoundException -> { - response.status = HttpServletResponse.SC_FORBIDDEN - response.sendError(HttpServletResponse.SC_FORBIDDEN, e.message) - } + } + } - else -> throw e + private fun processAuthorization( + request: HttpServletRequest, + filterChain: FilterChain, + response: HttpServletResponse + ) { + val token = getTokenFromRequest(request) + if (token != null) { + val usernameFromToken = getUsernameFromToken(token) + userService.findUserByUid(usernameFromToken)?.let { + val dokyUserDetails = DokyUserDetails(it) + val authenticationToken = + UsernamePasswordAuthenticationToken(dokyUserDetails, null, dokyUserDetails.getAuthorities()) + SecurityContextHolder.getContext().authentication = authenticationToken } + } else { + LOG.warn("Token is not presented. Clear authorization context.") + SecurityContextHolder.clearContext() } + filterChain.doFilter(request, response) } private fun getTokenFromRequest(request: HttpServletRequest): String? { diff --git a/server/src/main/kotlin/org/hkurh/doky/security/JwtProvider.kt b/server/src/main/kotlin/org/hkurh/doky/security/JwtProvider.kt index e034aa09..40167c4d 100644 --- a/server/src/main/kotlin/org/hkurh/doky/security/JwtProvider.kt +++ b/server/src/main/kotlin/org/hkurh/doky/security/JwtProvider.kt @@ -13,23 +13,27 @@ import org.springframework.stereotype.Component @Component object JwtProvider { private val jwtParser = Jwts.parserBuilder().setSigningKey(DokyApplication.SECRET_KEY_SPEC).build() + private const val USERNAME_KEY = "username" + private const val ROLES_KEY = "roles" /** * Generates a token for the given username. * * @param username The username for which to generate the token. + * @param roles The list of roles associated to user. * @return The generated token. */ - fun generateToken(username: String): String { + fun generateToken(username: String, roles: Set): String { val currentTime = DateTime(DateTimeZone.getDefault()) val expireTokenTime = currentTime.plusDays(1) + val claims = mapOf(USERNAME_KEY to username, ROLES_KEY to roles) return Jwts.builder() - .setId("dokyToken") - .setSubject(username) - .setIssuedAt(currentTime.toDate()) - .setExpiration(expireTokenTime.toDate()) - .signWith(DokyApplication.SECRET_KEY_SPEC, SignatureAlgorithm.HS256) - .compact() + .setId("dokyToken") + .setClaims(claims) + .setIssuedAt(currentTime.toDate()) + .setExpiration(expireTokenTime.toDate()) + .signWith(DokyApplication.SECRET_KEY_SPEC, SignatureAlgorithm.HS256) + .compact() } /** @@ -39,6 +43,6 @@ object JwtProvider { * @return The extracted username. */ fun getUsernameFromToken(token: String): String { - return jwtParser.parseClaimsJws(token).body.subject + return jwtParser.parseClaimsJws(token).body[USERNAME_KEY].toString() } } diff --git a/server/src/main/kotlin/org/hkurh/doky/security/UserAuthority.kt b/server/src/main/kotlin/org/hkurh/doky/security/UserAuthority.kt new file mode 100644 index 00000000..a040c6b3 --- /dev/null +++ b/server/src/main/kotlin/org/hkurh/doky/security/UserAuthority.kt @@ -0,0 +1,6 @@ +package org.hkurh.doky.security + +enum class UserAuthority { + ROLE_USER, + ROLE_ADMIN +} diff --git a/server/src/main/kotlin/org/hkurh/doky/security/WebSecurityConfig.kt b/server/src/main/kotlin/org/hkurh/doky/security/WebSecurityConfig.kt index 3a7364d5..6a761047 100644 --- a/server/src/main/kotlin/org/hkurh/doky/security/WebSecurityConfig.kt +++ b/server/src/main/kotlin/org/hkurh/doky/security/WebSecurityConfig.kt @@ -26,7 +26,7 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource */ @EnableWebSecurity @Configuration -@EnableMethodSecurity(securedEnabled = true) +@EnableMethodSecurity(prePostEnabled = true) internal class WebSecurityConfig(private val jwtAuthorizationFilter: JwtAuthorizationFilter) { @Bean @Throws(Exception::class) diff --git a/server/src/main/kotlin/org/hkurh/doky/users/UserFacade.kt b/server/src/main/kotlin/org/hkurh/doky/users/UserFacade.kt index aa979c38..6413db77 100644 --- a/server/src/main/kotlin/org/hkurh/doky/users/UserFacade.kt +++ b/server/src/main/kotlin/org/hkurh/doky/users/UserFacade.kt @@ -12,7 +12,7 @@ interface UserFacade { * @param userUid The user's unique ID. * @param password The user's password. */ - fun checkCredentials(userUid: String, password: String) + fun checkCredentials(userUid: String, password: String): UserDto /** * Registers a new user with the given user UID and password. diff --git a/server/src/main/kotlin/org/hkurh/doky/users/api/UserController.kt b/server/src/main/kotlin/org/hkurh/doky/users/api/UserController.kt index b4f0e42e..1bb229a1 100644 --- a/server/src/main/kotlin/org/hkurh/doky/users/api/UserController.kt +++ b/server/src/main/kotlin/org/hkurh/doky/users/api/UserController.kt @@ -1,9 +1,8 @@ package org.hkurh.doky.users.api -import org.hkurh.doky.security.DokyAuthority import org.hkurh.doky.users.UserFacade import org.springframework.http.ResponseEntity -import org.springframework.security.access.annotation.Secured +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PutMapping @@ -16,7 +15,7 @@ import org.springframework.web.bind.annotation.RestController * @param userFacade The [UserFacade] instance for handling user-related business logic. */ @RestController -@Secured(DokyAuthority.Role.ROLE_USER) +@PreAuthorize("hasRole('ROLE_USER')") class UserController(private val userFacade: UserFacade) : UserApi { @GetMapping("/users/current") diff --git a/server/src/main/kotlin/org/hkurh/doky/users/api/UserDto.kt b/server/src/main/kotlin/org/hkurh/doky/users/api/UserDto.kt index 09f15aee..de30dd11 100644 --- a/server/src/main/kotlin/org/hkurh/doky/users/api/UserDto.kt +++ b/server/src/main/kotlin/org/hkurh/doky/users/api/UserDto.kt @@ -1,6 +1,6 @@ package org.hkurh.doky.users.api -import jakarta.validation.constraints.Email +import com.fasterxml.jackson.annotation.JsonIgnore import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size @@ -11,13 +11,17 @@ import jakarta.validation.constraints.Size * @property uid The email of the user. Must be a valid email address. * @property name The name of the user. * @property password The password of the user. + * @property roles The list of roles assigned to user. Is not added to response. */ -data class UserDto( - var id: Long, - @Email - var uid: String, - var name: String? = null, +class UserDto { + var id: Long = 0 + var uid: String = "" + var name: String? = null + @Size(min = 8, max = 32, message = "Length should be from 8 to 32 characters") @Pattern(regexp = "^[a-zA-Z\\d!@#$%^&*()_\\-+]*$") var password: String? = null -) + + @JsonIgnore + var roles: MutableSet = mutableSetOf() +} diff --git a/server/src/main/kotlin/org/hkurh/doky/users/db/AuthorityEntity.kt b/server/src/main/kotlin/org/hkurh/doky/users/db/AuthorityEntity.kt new file mode 100644 index 00000000..58b36bdf --- /dev/null +++ b/server/src/main/kotlin/org/hkurh/doky/users/db/AuthorityEntity.kt @@ -0,0 +1,29 @@ +package org.hkurh.doky.users.db + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import org.hkurh.doky.security.UserAuthority + +@Entity +@Table( + name = "authorities", + uniqueConstraints = [UniqueConstraint(name = "uc_authorities_authority", columnNames = ["authority"])] +) +class AuthorityEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + var id: Long = -1 + + @Enumerated(EnumType.STRING) + @Column(name = "authority", nullable = false, unique = true) + var authority: UserAuthority = UserAuthority.ROLE_USER +} diff --git a/server/src/main/kotlin/org/hkurh/doky/users/db/UserEntity.kt b/server/src/main/kotlin/org/hkurh/doky/users/db/UserEntity.kt index 6d677f05..3e085ec2 100644 --- a/server/src/main/kotlin/org/hkurh/doky/users/db/UserEntity.kt +++ b/server/src/main/kotlin/org/hkurh/doky/users/db/UserEntity.kt @@ -2,10 +2,14 @@ package org.hkurh.doky.users.db import jakarta.persistence.Column import jakarta.persistence.Entity +import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Index +import jakarta.persistence.JoinColumn +import jakarta.persistence.JoinTable +import jakarta.persistence.ManyToMany import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint @@ -27,4 +31,12 @@ class UserEntity { @Column(name = "password", nullable = false) var password: String = "" + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "user_authorities", + joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")], + inverseJoinColumns = [JoinColumn(name = "authority_id", referencedColumnName = "id")] + ) + var authorities: MutableSet = HashSet() } diff --git a/server/src/main/kotlin/org/hkurh/doky/users/impl/DefaultUserFacade.kt b/server/src/main/kotlin/org/hkurh/doky/users/impl/DefaultUserFacade.kt index f7f57353..cb1171d5 100644 --- a/server/src/main/kotlin/org/hkurh/doky/users/impl/DefaultUserFacade.kt +++ b/server/src/main/kotlin/org/hkurh/doky/users/impl/DefaultUserFacade.kt @@ -14,12 +14,13 @@ import org.springframework.stereotype.Component @Component class DefaultUserFacade(private val userService: UserService, private val passwordEncoder: PasswordEncoder) : UserFacade { - override fun checkCredentials(userUid: String, password: String) { + override fun checkCredentials(userUid: String, password: String): UserDto { if (!userService.exists(userUid)) throw DokyAuthenticationException("User doesn't exist") val userEntity = userService.findUserByUid(userUid) val encodedPassword = userEntity!!.password if (!passwordEncoder.matches(password, encodedPassword)) throw DokyAuthenticationException("Incorrect credentials") + return userEntity.toDto() } override fun register(userUid: String, password: String): UserDto { diff --git a/server/src/main/resources/db/migration/V16_0__add_user_roles_structure.sql b/server/src/main/resources/db/migration/V16_0__add_user_roles_structure.sql new file mode 100644 index 00000000..d71d72d1 --- /dev/null +++ b/server/src/main/resources/db/migration/V16_0__add_user_roles_structure.sql @@ -0,0 +1,24 @@ +CREATE TABLE authorities +( + id BIGINT AUTO_INCREMENT NOT NULL, + authority VARCHAR(255) NOT NULL, + CONSTRAINT pk_authorities PRIMARY KEY (id) +); + +CREATE TABLE user_authorities +( + authority_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + CONSTRAINT pk_user_authorities PRIMARY KEY (authority_id, user_id) +); + +ALTER TABLE authorities + ADD CONSTRAINT uc_authorities_authority UNIQUE (authority); + +ALTER TABLE user_authorities + ADD CONSTRAINT fk_user_authorities_on_authority FOREIGN KEY (authority_id) REFERENCES authorities (id); + +ALTER TABLE user_authorities + ADD CONSTRAINT fk_user_authorities_on_user FOREIGN KEY (user_id) REFERENCES user (id); + +CREATE INDEX idx_user_authorities_user_id ON user_authorities (user_id) diff --git a/server/src/main/resources/db/migration/V16_1__add_roles_to_existing_users.sql b/server/src/main/resources/db/migration/V16_1__add_roles_to_existing_users.sql new file mode 100644 index 00000000..6af0ed61 --- /dev/null +++ b/server/src/main/resources/db/migration/V16_1__add_roles_to_existing_users.sql @@ -0,0 +1,7 @@ +INSERT IGNORE INTO authorities (authority) VALUES ('ROLE_USER'); +INSERT IGNORE INTO authorities (authority) VALUES ('ROLE_ADMIN'); + +INSERT IGNORE INTO user_authorities (user_id, authority_id) +SELECT u.id, a.id +FROM user u, authorities a +WHERE a.authority = 'ROLE_USER'; diff --git a/server/src/main/resources/db/migration/afterMigrateError__remove_failed_migrations.sql b/server/src/main/resources/db/migration/afterMigrateError__remove_failed_migrations.sql new file mode 100644 index 00000000..ee0cbb6c --- /dev/null +++ b/server/src/main/resources/db/migration/afterMigrateError__remove_failed_migrations.sql @@ -0,0 +1 @@ +DELETE FROM flyway_schema_history WHERE success=false; diff --git a/server/src/test/kotlin/org/hkurh/doky/authorization/AuthorizationUserControllerTest.kt b/server/src/test/kotlin/org/hkurh/doky/authorization/AuthorizationUserControllerTest.kt index 2c692695..b593d81a 100644 --- a/server/src/test/kotlin/org/hkurh/doky/authorization/AuthorizationUserControllerTest.kt +++ b/server/src/test/kotlin/org/hkurh/doky/authorization/AuthorizationUserControllerTest.kt @@ -2,10 +2,12 @@ package org.hkurh.doky.authorization import org.hkurh.doky.DokyUnitTest import org.hkurh.doky.users.UserFacade +import org.hkurh.doky.users.api.UserDto import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever @DisplayName("AuthorizationUserController unit test") @@ -19,10 +21,18 @@ class AuthorizationUserControllerTest : DokyUnitTest { @DisplayName("Should generate token when login") fun shouldGenerateToken_whenLogin() { // given + val userUid = "test@example.com" + val userPassword = "password123" val authenticationRequest = AuthenticationRequest().apply { - uid = "test@example.com" - password = "password123" + uid = userUid + password = userPassword } + whenever(userFacade.checkCredentials(userUid, userPassword)).thenReturn( + UserDto().apply { + uid = userUid + roles = mutableSetOf("ROLE_USER") + } + ) // when val response = controller.login(authenticationRequest) diff --git a/server/src/test/kotlin/org/hkurh/doky/security/JwtAuthorizationFilterTest.kt b/server/src/test/kotlin/org/hkurh/doky/security/JwtAuthorizationFilterTest.kt index 37f8ed5d..0e7dee8c 100644 --- a/server/src/test/kotlin/org/hkurh/doky/security/JwtAuthorizationFilterTest.kt +++ b/server/src/test/kotlin/org/hkurh/doky/security/JwtAuthorizationFilterTest.kt @@ -4,7 +4,9 @@ import jakarta.servlet.http.HttpServletResponse import org.hkurh.doky.DokyUnitTest import org.hkurh.doky.errorhandling.DokyNotFoundException import org.hkurh.doky.security.JwtProvider.generateToken +import org.hkurh.doky.toDto import org.hkurh.doky.users.UserService +import org.hkurh.doky.users.db.AuthorityEntity import org.hkurh.doky.users.db.UserEntity import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName @@ -36,13 +38,14 @@ class JwtAuthorizationFilterTest : DokyUnitTest { @DisplayName("Should add user to security context when token is valid") fun shouldAddUserToSecurityContext_whenTokenIsValid() { // given - val token = generateToken(userUid) - val request = MockHttpServletRequest() - request.addHeader(authorizationHeader, "Bearer $token") - val userEntity = UserEntity().apply { uid = userUid + authorities = mutableSetOf(AuthorityEntity()) } + val userDto = userEntity.toDto() + val token = generateToken(userDto.uid, userDto.roles) + val request = MockHttpServletRequest() + request.addHeader(authorizationHeader, "Bearer $token") whenever(userService.findUserByUid(userUid)).thenReturn(userEntity) // when @@ -76,7 +79,12 @@ class JwtAuthorizationFilterTest : DokyUnitTest { @DisplayName("Should set response error status when user is incorrect") fun shouldSetResponseError_whenIncorrectUser() { // given - val token = generateToken(userUid) + val userEntity = UserEntity().apply { + uid = userUid + authorities = mutableSetOf(AuthorityEntity()) + } + val userDto = userEntity.toDto() + val token = userDto.name?.let { generateToken(it, userDto.roles) } val request = MockHttpServletRequest() request.addHeader(authorizationHeader, "Bearer $token") whenever(userService.findUserByUid(userUid)).thenThrow(DokyNotFoundException::class.java)