Skip to content

Commit

Permalink
✨ Add admin role to backend
Browse files Browse the repository at this point in the history
  • Loading branch information
Poeschl committed Mar 24, 2024
1 parent cec5dc6 commit d00f927
Show file tree
Hide file tree
Showing 7 changed files with 94 additions and 8 deletions.
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GrantedAuthority> = listOf(SimpleGrantedAuthority("user"))
override fun getAuthorities(): Collection<GrantedAuthority> =
if (username == ROOT_USERNAME) listOf(SimpleGrantedAuthority(ROLE_ADMIN)) else listOf(SimpleGrantedAuthority(ROLE_USER))

override fun isAccountNonExpired() = true

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -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!")
}
Expand All @@ -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("")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down

0 comments on commit d00f927

Please sign in to comment.