Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
jvm:
uses: 'JorisJonkers-dev/github-workflows/.github/workflows/jvm-ci.yml@v0.8.0'
with:
lint-gradle-args: ':api:detekt :api:ktlintCheck'
lint-gradle-args: ':api:detektMain :api:detektTest :api:detektIntegrationTest :api:ktlintCheck'
test-gradle-args: >-
:api:test
:api:integrationTest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import org.testcontainers.containers.GenericContainer
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import org.testcontainers.rabbitmq.RabbitMQContainer
import org.testcontainers.utility.DockerImageName

@Tag("integration")
@SpringBootTest
Expand Down Expand Up @@ -41,9 +42,8 @@ abstract class IntegrationTestBase {
withPassword("auth_password")
}

@Suppress("DEPRECATION")
private val valkey =
GenericContainer<Nothing>("valkey/valkey:7-alpine").apply {
GenericContainer<Nothing>(DockerImageName.parse("valkey/valkey:7-alpine")).apply {
withExposedPorts(6379)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1018,7 +1018,6 @@ class OAuth2FlowIntegrationTest : IntegrationTestBase() {
}
}

@Suppress("DEPRECATION")
@Test
fun `authorization consent is not required for configured clients`() {
val username = uniqueUsername()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,12 @@ class HealthIntegrationTest : IntegrationTestBase() {
// the auto-registered discoveryComposite is intentionally
// uninitialised in this stack; that should not break the test.
val downComponents =
@Suppress("DEPRECATION")
components
.fields()
.asSequence()
.properties()
.filter { (_, node) ->
val status = node["status"]?.asText()
status == "DOWN" || status == "OUT_OF_SERVICE"
}.map { (name, node) -> "$name=${node["status"]?.asText()}" }
.toList()

assertThat(downComponents)
.describedAs("composite UP but a contributor is DOWN: $downComponents full=$body")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package com.jorisjonkers.personalstack.auth

import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableAsync

@EnableAsync
@SpringBootApplication(scanBasePackages = ["com.jorisjonkers.personalstack"])
class AuthApiApplication

fun main(args: Array<String>) {
runApplication<AuthApiApplication>(*args)
SpringApplication.run(arrayOf(AuthApiApplication::class.java), args)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.jorisjonkers.personalstack.auth.application.command

import com.jorisjonkers.personalstack.auth.domain.exception.InvalidPasswordException
import com.jorisjonkers.personalstack.auth.domain.exception.UserNotFoundException
import com.jorisjonkers.personalstack.auth.domain.model.UserCredentials
import com.jorisjonkers.personalstack.auth.domain.model.UserId
import com.jorisjonkers.personalstack.auth.domain.port.PasswordEncoder
import com.jorisjonkers.personalstack.auth.domain.port.UserRepository
import com.jorisjonkers.personalstack.common.command.CommandHandler
Expand All @@ -12,20 +14,16 @@ class ChangePasswordCommandHandler(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
) : CommandHandler<ChangePasswordCommand> {
@Suppress("ThrowsCount")
override fun handle(command: ChangePasswordCommand) {
val user =
userRepository.findById(command.userId)
?: throw UserNotFoundException(command.userId)
val credentials =
userRepository.findCredentialsByUsername(user.username)
?: throw UserNotFoundException(command.userId)

val credentials = resolveCredentials(command.userId)
if (!passwordEncoder.matches(command.currentPassword, credentials.passwordHash)) {
throw InvalidPasswordException()
}
userRepository.updatePassword(command.userId, passwordEncoder.encode(command.newPassword))
}

val newHash = passwordEncoder.encode(command.newPassword)
userRepository.updatePassword(command.userId, newHash)
private fun resolveCredentials(userId: UserId): UserCredentials {
val user = userRepository.findById(userId) ?: throw UserNotFoundException(userId)
return userRepository.findCredentialsByUsername(user.username) ?: throw UserNotFoundException(userId)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.jorisjonkers.personalstack.auth.application.command

import com.jorisjonkers.personalstack.auth.domain.exception.InvalidConfirmationTokenException
import com.jorisjonkers.personalstack.auth.domain.model.EmailConfirmationToken
import com.jorisjonkers.personalstack.auth.domain.port.EmailConfirmationTokenRepository
import com.jorisjonkers.personalstack.auth.domain.port.UserRepository
import com.jorisjonkers.personalstack.common.command.CommandHandler
Expand All @@ -12,26 +13,25 @@ class ConfirmEmailCommandHandler(
private val emailConfirmationTokenRepository: EmailConfirmationTokenRepository,
private val userRepository: UserRepository,
) : CommandHandler<ConfirmEmailCommand> {
@Suppress("ThrowsCount")
override fun handle(command: ConfirmEmailCommand) {
val token =
emailConfirmationTokenRepository.findByToken(command.token)
?: throw InvalidConfirmationTokenException("Confirmation token not found")

if (token.isUsed()) {
throw InvalidConfirmationTokenException("Confirmation token has already been used")
}

if (token.isExpired()) {
throw InvalidConfirmationTokenException("Confirmation token has expired")
}

val token = resolveValidToken(command.token)
val user =
userRepository.findById(token.userId)
?: throw InvalidConfirmationTokenException("User not found for confirmation token")

userRepository.update(user.copy(emailConfirmed = true, updatedAt = Instant.now()))

emailConfirmationTokenRepository.save(token.copy(usedAt = Instant.now()))
}

private fun resolveValidToken(rawToken: String): EmailConfirmationToken {
val token = emailConfirmationTokenRepository.findByToken(rawToken)
val invalidReason =
when {
token == null -> "Confirmation token not found"
token.isUsed() -> "Confirmation token has already been used"
token.isExpired() -> "Confirmation token has expired"
else -> null
}
invalidReason?.let { throw InvalidConfirmationTokenException(it) }
return checkNotNull(token) { "token was validated non-null above" }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,23 @@ class RegisterUserCommandHandler(
private val rabbitMqMessagingProperties: RabbitMqMessagingProperties,
private val emailConfirmationTokenRepository: EmailConfirmationTokenRepository,
) : CommandHandler<RegisterUserCommand> {
@Suppress("LongMethod")
override fun handle(command: RegisterUserCommand) {
checkNoDuplicates(command)
val savedUser = createUser(command)
val confirmationToken = createAndSaveConfirmationToken(savedUser)
publishEvents(savedUser, confirmationToken)
}

private fun checkNoDuplicates(command: RegisterUserCommand) {
if (userRepository.existsByUsername(command.username)) {
throw DuplicateUsernameException(command.username)
}
if (userRepository.existsByEmail(command.email)) {
throw DuplicateEmailException(command.email)
}
}

private fun createUser(command: RegisterUserCommand): User {
val now = Instant.now()
val user =
User(
Expand All @@ -53,24 +61,32 @@ class RegisterUserCommandHandler(
updatedAt = now,
)
val passwordHash = passwordEncoder.encode(command.password)
val savedUser = userRepository.create(user, passwordHash)
return userRepository.create(user, passwordHash)
}

val confirmationToken =
private fun createAndSaveConfirmationToken(user: User): EmailConfirmationToken {
val token =
EmailConfirmationToken(
id = UUID.randomUUID(),
userId = savedUser.id,
userId = user.id,
token = UUID.randomUUID().toString(),
expiresAt = Instant.now().plus(TOKEN_EXPIRY_HOURS, ChronoUnit.HOURS),
usedAt = null,
createdAt = Instant.now(),
)
emailConfirmationTokenRepository.save(confirmationToken)
emailConfirmationTokenRepository.save(token)
return token
}

private fun publishEvents(
user: User,
confirmationToken: EmailConfirmationToken,
) {
val event =
UserRegisteredEvent(
userId = savedUser.id,
username = savedUser.username,
email = savedUser.email,
userId = user.id,
username = user.username,
email = user.email,
)
// Intra-service event (Spring Modulith)
eventPublisher.publishEvent(event)
Expand All @@ -80,9 +96,9 @@ class RegisterUserCommandHandler(
// Email confirmation event
eventPublisher.publishEvent(
EmailConfirmationRequestedEvent(
userId = savedUser.id,
username = savedUser.username,
email = savedUser.email,
userId = user.id,
username = user.username,
email = user.email,
confirmationToken = confirmationToken.token,
),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.jorisjonkers.personalstack.auth.application.command

import com.jorisjonkers.personalstack.auth.domain.exception.InvalidResetTokenException
import com.jorisjonkers.personalstack.auth.domain.model.PasswordResetToken
import com.jorisjonkers.personalstack.auth.domain.port.PasswordEncoder
import com.jorisjonkers.personalstack.auth.domain.port.PasswordResetTokenRepository
import com.jorisjonkers.personalstack.auth.domain.port.UserRepository
Expand All @@ -14,22 +15,22 @@ class ResetPasswordCommandHandler(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
) : CommandHandler<ResetPasswordCommand> {
@Suppress("ThrowsCount")
override fun handle(command: ResetPasswordCommand) {
val token =
passwordResetTokenRepository.findByToken(command.token)
?: throw InvalidResetTokenException("Password reset token not found")

if (token.isUsed()) {
throw InvalidResetTokenException("Password reset token has already been used")
}
if (token.isExpired()) {
throw InvalidResetTokenException("Password reset token has expired")
}

val newHash = passwordEncoder.encode(command.newPassword)
userRepository.updatePassword(token.userId, newHash)

val token = resolveValidToken(command.token)
userRepository.updatePassword(token.userId, passwordEncoder.encode(command.newPassword))
passwordResetTokenRepository.save(token.copy(usedAt = Instant.now()))
}

private fun resolveValidToken(rawToken: String): PasswordResetToken {
val token = passwordResetTokenRepository.findByToken(rawToken)
val invalidReason =
when {
token == null -> "Password reset token not found"
token.isUsed() -> "Password reset token has already been used"
token.isExpired() -> "Password reset token has expired"
else -> null
}
invalidReason?.let { throw InvalidResetTokenException(it) }
return checkNotNull(token) { "token was validated non-null above" }
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
package com.jorisjonkers.personalstack.auth.config

import com.jorisjonkers.personalstack.auth.domain.model.Role
import com.jorisjonkers.personalstack.auth.domain.model.ServicePermission
import com.jorisjonkers.personalstack.auth.domain.model.UserCredentials
import com.jorisjonkers.personalstack.auth.domain.model.UserId
import com.jorisjonkers.personalstack.auth.domain.port.UserRepository
import com.jorisjonkers.personalstack.auth.infrastructure.security.AuthenticatedUser
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
Expand All @@ -23,19 +20,14 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.ser
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.jackson.SecurityJacksonModules
import org.springframework.security.oauth2.core.oidc.OidcScopes
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository
import org.springframework.security.oauth2.server.authorization.jackson.OAuth2AuthorizationServerJacksonModule
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint
import org.springframework.security.web.context.HttpSessionSecurityContextRepository
Expand Down Expand Up @@ -186,47 +178,6 @@ class AuthorizationServerConfig(
registeredClientRepository,
)

@Bean
@Suppress("LongMethod")
fun jwtTokenCustomizer(userRepository: UserRepository): OAuth2TokenCustomizer<JwtEncodingContext> =
OAuth2TokenCustomizer { context ->
val principal = context.getPrincipal<Authentication>()
val credentials =
userRepository.findCredentialsByUsername(principal.name) ?: return@OAuth2TokenCustomizer
val roles = buildRoles(credentials)

when {
context.tokenType == OAuth2TokenType.ACCESS_TOKEN -> {
context.claims.claim("roles", roles)
context.claims.claim("username", credentials.username)
context.claims.claim("preferred_username", credentials.username)
context.claims.claim("email", credentials.email)
context.claims.claim("aud", listOf(context.registeredClient.clientId))
context.claims.subject(credentials.userId.value.toString())
}

context.tokenType.value == OidcParameterNames.ID_TOKEN -> {
context.claims.claim("roles", roles)
context.claims.subject(credentials.userId.value.toString())

if (OidcScopes.PROFILE in context.authorizedScopes) {
context.claims.claim("preferred_username", credentials.username)
context.claims.claim("name", "${credentials.firstName} ${credentials.lastName}")
}

if (OidcScopes.EMAIL in context.authorizedScopes) {
context.claims.claim("email", credentials.email)
context.claims.claim("email_verified", credentials.emailConfirmed)
}

val k8sGroups = kubernetesGroups(credentials)
if (k8sGroups.isNotEmpty() && "groups" in context.authorizedScopes) {
context.claims.claim("groups", k8sGroups)
}
}
}
}

@Bean
fun authorizationServerSettings(): AuthorizationServerSettings =
AuthorizationServerSettings
Expand All @@ -240,31 +191,6 @@ class AuthorizationServerConfig(
.oidcUserInfoEndpoint("/api/userinfo")
.build()

private fun buildRoles(credentials: UserCredentials): List<String> =
buildList {
add("ROLE_${credentials.role.name}")
if (credentials.role == Role.ADMIN) {
addAll(ServicePermission.entries.map { "SERVICE_${it.name}" })
} else {
addAll(credentials.servicePermissions.map { "SERVICE_${it.name}" })
}
}

// Kubernetes group membership for ID tokens issued to OIDC clients that
// talk to the k3s API server (Headlamp). The k3s control plane runs
// --oidc-groups-claim=groups --oidc-groups-prefix=oidc: and we bind
// oidc:k8s-admin to the cluster-admin ClusterRole. DASHBOARD permission
// therefore grants full cluster management, matching how the host-level
// forward-auth already gates access to dashboard.jorisjonkers.dev.
private fun kubernetesGroups(credentials: UserCredentials): List<String> =
if (credentials.role == Role.ADMIN ||
ServicePermission.DASHBOARD in credentials.servicePermissions
) {
listOf("k8s-admin")
} else {
emptyList()
}

private fun downstreamClientAuthorizationFilter(): OncePerRequestFilter =
object : OncePerRequestFilter() {
override fun shouldNotFilter(request: HttpServletRequest): Boolean {
Expand Down
Loading
Loading