From d00f927099a15aeb50398110e33b568579194a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20P=C3=B6schl?= Date: Sat, 23 Mar 2024 16:41:04 +0100 Subject: [PATCH] :sparkles: Add admin role to backend --- README.md | 27 ++++++++++++---- .../controller/RobotRestController.kt | 3 ++ .../security/repository/UserRepository.kt | 9 +++++- .../security/service/UserDetailsService.kt | 31 +++++++++++++++++++ .../security/utils/JwtTokenProvider.kt | 2 ++ .../security/repository/UserTest.kt | 28 +++++++++++++++++ .../service/UserDetailsServiceTest.kt | 2 +- 7 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 backend/src/test/kotlin/xyz/poeschl/pathseeker/security/repository/UserTest.kt diff --git a/README.md b/README.md index 3793786c..ed09dadc 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,29 @@ For an easy setup, a docker-compose file is provided in the `deploy` folder. It is just a basic setup with traefik as reverse proxy on `http`. Depending on the environment a certificate for TLS is recommended. -Additional environment variables: +### Environment variables frontend -* `AUTH_ISSUER`: Set an explicit issuer string for the auth tokens. - This can be useful for parallel instances and should be set in production-like envs. -* `AUTH_KEY`: Set the input for the JWT signing key. - This should be a random string with the length of 64. If changed every user needs to re-login to make auth work correctly again. +* `PLAUSIBLE_DOMAIN`: The tracked domain for Plausible. +* `PLAUSIBLE_API_HOST` (optional): An alternative Plausible api host. If not set https://plausible.io is used. + +### Environment variables backend + +* `SPRING_DATASOURCE_*`: Those environment variables are used to connect to an external database. * `SPRING_PROFILES_ACTIVE`: Set this environment variable to `prod` to disable some dev features. It will also hide the OpenApi Docs for all internal interfaces. +* `INITIAL_ROOT_PASSWORD` (optional): The initial root user password. + If not set a random one is generated at first start and output in the backend log. +* `AUTH_ISSUER` (optional): Set an explicit issuer string for the auth tokens. + This can be useful for parallel instances and should be set in production-like envs. +* `AUTH_KEY`(optional): Set the input for the JWT signing key. + This should be a random string with the length of 64. If changed every user needs to re-login to make auth work correctly again. + +### Admin authentication + +At the first start the user `root` is created with a random password which gets displayed **one-time at the first backend start** in the start logs. +The password can also be specified via an environment variable, but keep it mind it will only be used one-time at the first start. + +The admin user can never participate in a game! ### Plausible tracking @@ -37,7 +52,7 @@ This software will get no versioning and lives on the bloody main branch. ### Requirements -Have a [Java 17 LTS](https://adoptium.net/de/temurin/releases/?package=jdk&version=17), [node 20](https://nodejs.org/en/download/) and +Have a [Java 21 LTS](https://adoptium.net/de/temurin/releases/?package=jdk&version=21), [node 20](https://nodejs.org/en/download/) and [python 3.10](https://www.python.org/downloads/) installation is required to make it all run. Make sure you have [podman](https://podman.io/docs/installation) and [podman-compose](https://github.com/containers/podman-compose) (or docker and docker-compose) installed on your system, since the dev environment runs on a container-based reverse proxy. diff --git a/backend/src/main/kotlin/xyz/poeschl/pathseeker/controller/RobotRestController.kt b/backend/src/main/kotlin/xyz/poeschl/pathseeker/controller/RobotRestController.kt index b72886ad..cfd49085 100644 --- a/backend/src/main/kotlin/xyz/poeschl/pathseeker/controller/RobotRestController.kt +++ b/backend/src/main/kotlin/xyz/poeschl/pathseeker/controller/RobotRestController.kt @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.Authentication import org.springframework.web.bind.annotation.* import org.springframework.web.server.ResponseStatusException @@ -39,6 +40,7 @@ class RobotRestController(private val robotService: RobotService) { ) @SecurityRequirement(name = "Bearer Authentication") @GetMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) + @PreAuthorize("!principal.authorities.contains('${User.ROLE_ADMIN}')") fun getActiveUserRobot(auth: Authentication): ActiveRobot { LOGGER.debug("Get active user robot") return robotService.getActiveRobotByUser(auth.principal as User) ?: throw RobotNotActiveException("Your robot is not active right now") @@ -50,6 +52,7 @@ class RobotRestController(private val robotService: RobotService) { ) @SecurityRequirement(name = "Bearer Authentication") @PostMapping("/attend") + @PreAuthorize("!principal.authorities.contains('${User.ROLE_ADMIN}')") fun registerRobotForGame(auth: Authentication) { val robot = robotService.getRobotByUser(auth.principal as User) if (robot != null) { diff --git a/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/repository/UserRepository.kt b/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/repository/UserRepository.kt index a5228d5e..fc8f61c7 100644 --- a/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/repository/UserRepository.kt +++ b/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/repository/UserRepository.kt @@ -29,13 +29,20 @@ constructor( @CreatedDate @Column(name = "registered_at") var registeredAt: ZonedDateTime = ZonedDateTime.now() ) : UserDetails { + companion object { + const val ROLE_ADMIN = "ADMIN" + const val ROLE_USER = "USER" + const val ROOT_USERNAME = "root" + } + constructor(username: String, password: String) : this(null, username, password) override fun getUsername() = username override fun getPassword() = password - override fun getAuthorities(): Collection = listOf(SimpleGrantedAuthority("user")) + override fun getAuthorities(): Collection = + if (username == ROOT_USERNAME) listOf(SimpleGrantedAuthority(ROLE_ADMIN)) else listOf(SimpleGrantedAuthority(ROLE_USER)) override fun isAccountNonExpired() = true diff --git a/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/service/UserDetailsService.kt b/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/service/UserDetailsService.kt index fd0decf5..b22dae76 100644 --- a/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/service/UserDetailsService.kt +++ b/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/service/UserDetailsService.kt @@ -1,7 +1,10 @@ package xyz.poeschl.pathseeker.security.service import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.annotation.Configuration +import org.springframework.context.event.EventListener import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UsernameNotFoundException @@ -12,6 +15,7 @@ import xyz.poeschl.pathseeker.repositories.RobotRepository import xyz.poeschl.pathseeker.security.repository.User import xyz.poeschl.pathseeker.security.repository.UserRepository import xyz.poeschl.pathseeker.security.utils.JwtTokenProvider +import java.security.SecureRandom @Configuration class UserDetailsService( @@ -23,8 +27,12 @@ class UserDetailsService( companion object { private val LOGGER = LoggerFactory.getLogger(UserDetailsService::class.java) + private const val INITIAL_ROOT_PASSWORD_LENGTH = 32 } + @Value("\${INITIAL_ROOT_PASSWORD:}") + private val initialRootPassword: String = "" + override fun loadUserByUsername(username: String): UserDetails { return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("User '$username' not found!") } @@ -42,4 +50,27 @@ class UserDetailsService( null } } + + @EventListener(ApplicationReadyEvent::class) + fun createAdminIfNotExisting() { + if (userRepository.findByUsername(User.ROOT_USERNAME) == null) { + LOGGER.info("No admin user found with name '{}'", User.ROOT_USERNAME) + val password = initialRootPassword.ifBlank { generateRandomPassword(INITIAL_ROOT_PASSWORD_LENGTH) } + userRepository.save(User(User.ROOT_USERNAME, passwordEncoder.encode(password))) + LOGGER.info("################################") + LOGGER.info("Generated password for user '{}':", User.ROOT_USERNAME) + LOGGER.info("{}", password) + LOGGER.info("################################") + } else { + LOGGER.debug("Admin user found.") + } + } + + private fun generateRandomPassword(length: Int): String { + val charset = ('a'..'z') + ('A'..'Z') + ('0'..'9') + listOf('!', '@', '#', '$', '%', '?', '&', '*', '+', '-') + val secureRandom = SecureRandom() + return (1..length) + .map { charset[secureRandom.nextInt(charset.size)] } + .joinToString("") + } } diff --git a/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/utils/JwtTokenProvider.kt b/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/utils/JwtTokenProvider.kt index 1428415a..3cb54186 100644 --- a/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/utils/JwtTokenProvider.kt +++ b/backend/src/main/kotlin/xyz/poeschl/pathseeker/security/utils/JwtTokenProvider.kt @@ -20,6 +20,7 @@ class JwtTokenProvider { companion object { private val LOGGER = LoggerFactory.getLogger(JwtTokenProvider::class.java) + private const val ROLE_CLAIM_NAME = "role" } @Value("\${AUTH_ISSUER:PathSeeker}") @@ -41,6 +42,7 @@ class JwtTokenProvider { val user = authentication.principal as User return Jwts.builder() .subject(user.username) + .claim(ROLE_CLAIM_NAME, user.authorities.first().authority) .issuedAt(Date.from(now.toInstant())) .issuer(jwtIssuer) .signWith(key) diff --git a/backend/src/test/kotlin/xyz/poeschl/pathseeker/security/repository/UserTest.kt b/backend/src/test/kotlin/xyz/poeschl/pathseeker/security/repository/UserTest.kt new file mode 100644 index 00000000..0dc30f98 --- /dev/null +++ b/backend/src/test/kotlin/xyz/poeschl/pathseeker/security/repository/UserTest.kt @@ -0,0 +1,28 @@ +package xyz.poeschl.pathseeker.security.repository + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.security.core.authority.SimpleGrantedAuthority +import xyz.poeschl.pathseeker.test.utils.builder.Builders.Companion.a +import xyz.poeschl.pathseeker.test.utils.builder.NativeTypes.Companion.`$String` + +class UserTest { + + @Test + fun userAuthorities_regularUser() { + // THEN + val user = User(a(`$String`("user")), a(`$String`("password"))) + + // VERIFY + assertThat(user.authorities).containsExactly(SimpleGrantedAuthority("USER")) + } + + @Test + fun userAuthorities_adminUser() { + // THEN + val user = User("root", a(`$String`("password"))) + + // VERIFY + assertThat(user.authorities).containsExactly(SimpleGrantedAuthority("ADMIN")) + } +} diff --git a/backend/src/test/kotlin/xyz/poeschl/pathseeker/security/service/UserDetailsServiceTest.kt b/backend/src/test/kotlin/xyz/poeschl/pathseeker/security/service/UserDetailsServiceTest.kt index d48f6932..aced3fca 100644 --- a/backend/src/test/kotlin/xyz/poeschl/pathseeker/security/service/UserDetailsServiceTest.kt +++ b/backend/src/test/kotlin/xyz/poeschl/pathseeker/security/service/UserDetailsServiceTest.kt @@ -80,7 +80,7 @@ class UserDetailsServiceTest { assertThat(userSlot.captured.username).isEqualTo(userName) assertThat(userSlot.captured.password).isEqualTo(encodedPassword) assertThat(userSlot.captured.registeredAt).isAfterOrEqualTo(ZonedDateTime.now().minusSeconds(5)) - assertThat(userSlot.captured.authorities).containsExactly(SimpleGrantedAuthority("user")) + assertThat(userSlot.captured.authorities).containsExactly(SimpleGrantedAuthority("USER")) assertThat(userSlot.captured.isEnabled).isTrue() assertThat(userSlot.captured.isAccountNonLocked).isTrue() assertThat(userSlot.captured.isAccountNonExpired).isTrue()