diff --git a/build.gradle.kts b/build.gradle.kts index dfea082e..4859b590 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,8 @@ dependencies { implementation("org.influxdb:influxdb-java") implementation("org.bouncycastle:bcpkix-jdk15on:1.70") + implementation("io.jsonwebtoken:jjwt:0.9.1") + testImplementation("org.jetbrains.kotlin:kotlin-test:1.6.21") testImplementation("org.springframework.boot:spring-boot-starter-test") @@ -86,6 +88,7 @@ tasks.bootRun { val adminPassword: String? by project environment("cloudio.initialAdminPassword", adminPassword ?: "admin") + environment("cloudio.jwt.secret", "56mY2Gqw1BVRErXp71HLLyLfGqSjwxMQ1d9F89yPU1GCOPD_p3VrMoOrikxhaiD16vp-iavbXkXeo2WbXio4Qw") // Certificate manager. environment("cloudio.cert-manager.caCertificate", file("cloudio-dev-environment/certificates/ca.cer").readText()) diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/dao/ProvisionToken.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/dao/ProvisionToken.kt deleted file mode 100644 index 23867bd7..00000000 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/dao/ProvisionToken.kt +++ /dev/null @@ -1,28 +0,0 @@ -package ch.hevs.cloudio.cloud.dao - -import java.util.* -import javax.persistence.* - -@Entity -@Table(name = "cloudio_provision_token") -data class ProvisionToken( - @Column(unique = true, nullable = false, updatable = false, length = 255) - val token: String = generateToken(), - - @Column(nullable = false, updatable = false) - val endpointUUID: UUID = UUID(0, 0), - - @Column(nullable = false, updatable = false) - val expires: Date = Date() -) { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long = 0 - - companion object { - private fun generateToken(): String { - val alphabet: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') - return List(64) { alphabet.random() }.joinToString("") - } - } -} diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/dao/ProvisionTokenRepository.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/dao/ProvisionTokenRepository.kt deleted file mode 100644 index 72a52281..00000000 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/dao/ProvisionTokenRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ch.hevs.cloudio.cloud.dao - -import org.springframework.data.repository.CrudRepository -import org.springframework.stereotype.Repository -import java.util.* - -@Repository -interface ProvisionTokenRepository : CrudRepository { - fun findByToken(token: String): Optional - fun deleteByToken(token: String) - fun deleteByEndpointUUID(uuid: UUID) -} diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/main.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/main.kt index e09654a7..ce49da02 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/main.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/main.kt @@ -2,10 +2,7 @@ package ch.hevs.cloudio.cloud import ch.hevs.cloudio.cloud.cors.CorsRepository import ch.hevs.cloudio.cloud.internalservice.certificatemanager.CertificateManagerService -import ch.hevs.cloudio.cloud.security.Authority -import ch.hevs.cloudio.cloud.security.CloudioCorsConfigurationSource -import ch.hevs.cloudio.cloud.security.CloudioUserDetails -import ch.hevs.cloudio.cloud.security.CloudioUserDetailsService +import ch.hevs.cloudio.cloud.security.* import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.rabbitmq.client.DefaultSaslConfig @@ -34,13 +31,17 @@ import org.springframework.security.config.annotation.authentication.builders.Au import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter +import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.web.cors.CorsConfigurationSource import java.net.InetAddress import java.util.* import javax.net.ssl.KeyManagerFactory import javax.net.ssl.TrustManagerFactory +import javax.servlet.http.HttpServletResponse + @SpringBootApplication @ConfigurationPropertiesScan @@ -59,6 +60,11 @@ import javax.net.ssl.TrustManagerFactory type = SecuritySchemeType.HTTP, scheme = "basic" ) +@SecurityScheme( + name = "tokenAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer" +) class CloudioApplication { @Bean fun passwordEncoder() = BCryptPasswordEncoder() @@ -157,7 +163,7 @@ class CloudioApplication { fun corsConfigurationSource(repo: CorsRepository): CorsConfigurationSource = CloudioCorsConfigurationSource(repo) @Bean - fun webSecurityConfigurerAdapter(cloudioUserDetailsService: CloudioUserDetailsService) = object : WebSecurityConfigurerAdapter() { + fun webSecurityConfigurerAdapter(cloudioUserDetailsService: CloudioUserDetailsService, accessTokenFilter: AccessTokenFilter) = object : WebSecurityConfigurerAdapter() { override fun configure(auth: AuthenticationManagerBuilder) { auth.userDetailsService(cloudioUserDetailsService) } @@ -167,11 +173,12 @@ class CloudioApplication { .csrf().disable() .authorizeRequests().antMatchers( "/v3/api-docs", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", - "/api/v1/provision/*", "/messageformat/**" + "/api/v1/provision/*", "/messageformat/**", "/api/v1/auth/login" ).permitAll() .anyRequest().hasAuthority(Authority.HTTP_ACCESS.name) - .and().httpBasic() - .and().sessionManagement().disable() + .and().httpBasic().authenticationEntryPoint { _, response, authException -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.message) } + .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + http.addFilterBefore(accessTokenFilter, UsernamePasswordAuthenticationFilter::class.java) } } diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/account/AccountController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/account/AccountController.kt index d88a60ab..eed6325c 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/account/AccountController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/account/AccountController.kt @@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.context.annotation.Profile import org.springframework.http.HttpStatus @@ -27,7 +28,10 @@ import org.springframework.web.bind.annotation.* @Profile("rest-api") @Tag(name = "Account Management", description = "Allows users to access and modify their account information.") @RequestMapping("/api/v1/account") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) class AccountController( private val userRepository: UserRepository, private val permissionManager: CloudioPermissionManager, @@ -37,14 +41,17 @@ class AccountController( @ResponseStatus(HttpStatus.OK) @Transactional(readOnly = true) @Operation(summary = "Get information about the currently authenticated user.") - @ApiResponses(value = [ - ApiResponse(description = "Currently authenticated user's account.", responseCode = "200", content = [Content(schema = Schema(implementation = AccountEntity::class))]), - ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), - ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) - ]) + @ApiResponses( + value = [ + ApiResponse(description = "Currently authenticated user's account.", responseCode = "200", content = [Content(schema = Schema(implementation = AccountEntity::class))]), + ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), + ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) + ] + ) fun getMyAccount( - @Parameter(hidden = true) authentication: Authentication - ) = userRepository.findById(authentication.userDetails().id).orElseThrow { + @Parameter(hidden = true) authentication: Authentication? + ) = if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + else userRepository.findById(authentication.userDetails().id).orElseThrow { CloudioHttpExceptions.NotFound("User not found.") }.run { AccountEntity( @@ -59,17 +66,21 @@ class AccountController( @PutMapping("/password") @ResponseStatus(HttpStatus.NO_CONTENT) @Operation(summary = "Change the currently authenticated user's password.") - @ApiResponses(value = [ - ApiResponse(description = "Password was changed.", responseCode = "204", content = [Content()]), - ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), - ApiResponse(description = "Existing password is incorrect.", responseCode = "400", content = [Content()]), - ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) - ]) + @ApiResponses( + value = [ + ApiResponse(description = "Password was changed.", responseCode = "204", content = [Content()]), + ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), + ApiResponse(description = "Existing password is incorrect.", responseCode = "400", content = [Content()]), + ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) + ] + ) fun putMyPassword( @RequestParam @Parameter(description = "Existing password.") existingPassword: String, @RequestParam @Parameter(description = "New password.") newPassword: String, - @Parameter(hidden = true) authentication: Authentication + @Parameter(hidden = true) authentication: Authentication? ) { + if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + userRepository.findById(authentication.userDetails().id).orElseThrow { CloudioHttpExceptions.NotFound("User not found.") }.let { @@ -84,30 +95,37 @@ class AccountController( @GetMapping("/email", produces = ["text/plain"]) @ResponseStatus(HttpStatus.OK) @Operation(summary = "Returns the currently authenticated user's email address.") - @ApiResponses(value = [ - ApiResponse(description = "Users email address.", responseCode = "200", content = [Content(schema = Schema(type = "string", example = "john.doe@theinternet.org"))]), - ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), - ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) - ]) + @ApiResponses( + value = [ + ApiResponse(description = "Users email address.", responseCode = "200", content = [Content(schema = Schema(type = "string", example = "john.doe@theinternet.org"))]), + ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), + ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) + ] + ) fun getMyEmailAddress( - @Parameter(hidden = true) authentication: Authentication - ) = userRepository.findById(authentication.userDetails().id).orElseThrow { + @Parameter(hidden = true) authentication: Authentication? + ) = if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + else userRepository.findById(authentication.userDetails().id).orElseThrow { CloudioHttpExceptions.NotFound("User not found.") }.emailAddress.toString() @PutMapping("/email") @ResponseStatus(HttpStatus.NO_CONTENT) @Operation(summary = "Changes the currently authenticated user's email address.") - @ApiResponses(value = [ - ApiResponse(description = "Email address was changed.", responseCode = "204", content = [Content()]), - ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), - ApiResponse(description = "Invalid Email address.", responseCode = "400", content = [Content()]), - ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) - ]) + @ApiResponses( + value = [ + ApiResponse(description = "Email address was changed.", responseCode = "204", content = [Content()]), + ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), + ApiResponse(description = "Invalid Email address.", responseCode = "400", content = [Content()]), + ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) + ] + ) fun putMyEmailAddress( - @Parameter(hidden = true) authentication: Authentication, + @Parameter(hidden = true) authentication: Authentication?, @RequestParam @Parameter(description = "Email address to assign to user.", example = "john.doe@theinternet.org") email: String ) { + if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + userRepository.findById(authentication.userDetails().id).orElseThrow { CloudioHttpExceptions.NotFound("User not found.") }.let { @@ -123,30 +141,37 @@ class AccountController( @GetMapping("/metaData", produces = [MediaType.APPLICATION_JSON_VALUE]) @ResponseStatus(HttpStatus.OK) @Operation(summary = "Returns the currently authenticated user's meta data.") - @ApiResponses(value = [ - ApiResponse(description = "Users meta data.", responseCode = "200", content = [Content(schema = Schema(type = "object", example = "{\"location\": \"Sion\", \"position\": \"Manager\"}"))]), - ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), - ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) - ]) + @ApiResponses( + value = [ + ApiResponse(description = "Users meta data.", responseCode = "200", content = [Content(schema = Schema(type = "object", example = "{\"location\": \"Sion\", \"position\": \"Manager\"}"))]), + ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), + ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) + ] + ) fun getMyMetaData( - @Parameter(hidden = true) authentication: Authentication - ) = userRepository.findById(authentication.userDetails().id).orElseThrow { + @Parameter(hidden = true) authentication: Authentication? + ) = if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + else userRepository.findById(authentication.userDetails().id).orElseThrow { CloudioHttpExceptions.NotFound("User not found.") }.metaData @PutMapping("/metaData", consumes = [MediaType.APPLICATION_JSON_VALUE]) @ResponseStatus(HttpStatus.NO_CONTENT) @Operation(summary = "Changes the currently authenticated user's meta data.") - @ApiResponses(value = [ - ApiResponse(description = "Meta data was modified.", responseCode = "204", content = [Content()]), - ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), - ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) - ]) + @ApiResponses( + value = [ + ApiResponse(description = "Meta data was modified.", responseCode = "204", content = [Content()]), + ApiResponse(description = "User not found.", responseCode = "404", content = [Content()]), + ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) + ] + ) fun putMyMetaData( - @Parameter(hidden = true) authentication: Authentication, + @Parameter(hidden = true) authentication: Authentication?, @RequestBody @Parameter(description = "User's metadata.", schema = Schema(type = "object", example = "{\"location\": \"Sion\", \"position\": \"Manager\"}")) body: Map ) { + if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + userRepository.findById(authentication.userDetails().id).orElseThrow { CloudioHttpExceptions.NotFound("User not found.") }.let { @@ -159,13 +184,16 @@ class AccountController( @ResponseStatus(HttpStatus.OK) @Transactional(readOnly = true) @Operation(summary = "Get the all endpoint permissions.") - @ApiResponses(value = [ - ApiResponse(description = "Users endpoint permissions.", responseCode = "200", content = [Content(array = ArraySchema(schema = Schema(implementation = EndpointPermissionEntity::class)))]), - ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) - ]) + @ApiResponses( + value = [ + ApiResponse(description = "Users endpoint permissions.", responseCode = "200", content = [Content(array = ArraySchema(schema = Schema(implementation = EndpointPermissionEntity::class)))]), + ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) + ] + ) fun getMyEndpointPermissions( - @Parameter(hidden = true) authentication: Authentication - ) = permissionManager.resolvePermissions(authentication.userDetails()).map { + @Parameter(hidden = true) authentication: Authentication? + ) = if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + else permissionManager.resolvePermissions(authentication.userDetails()).map { EndpointPermissionEntity( endpoint = it.endpointUUID, permission = it.permission, diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/cors/CorsManagementController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/cors/CorsManagementController.kt index 174bec5a..aeb53fc5 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/cors/CorsManagementController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/cors/CorsManagementController.kt @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.context.annotation.Profile import org.springframework.http.HttpStatus @@ -23,7 +24,10 @@ import org.springframework.web.bind.annotation.* @Profile("rest-api") @Tag(name = "Cors Management", description = "Allows an admin user to manage cors allowed origins.") @RequestMapping("/api/v1/admin") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) @Authority.HttpAdmin class CorsManagementController ( private val corsRepository: CorsRepository, diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/user/UserManagementController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/user/UserManagementController.kt index fcb0b61b..944bbc69 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/user/UserManagementController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/user/UserManagementController.kt @@ -14,6 +14,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.context.annotation.Profile import org.springframework.http.HttpStatus @@ -26,7 +27,10 @@ import org.springframework.web.bind.annotation.* @Profile("rest-api") @Tag(name = "User Management", description = "Allows a user with the authority HTTP_ADMIN to manage users and user groups.") @RequestMapping("/api/v1/admin") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) @Authority.HttpAdmin class UserManagementController( private var userRepository: UserRepository, diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/usergroup/UserGroupManagementController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/usergroup/UserGroupManagementController.kt index 716a6b4d..6df103a9 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/usergroup/UserGroupManagementController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/admin/usergroup/UserGroupManagementController.kt @@ -14,6 +14,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.context.annotation.Profile import org.springframework.http.HttpStatus @@ -25,7 +26,10 @@ import javax.transaction.Transactional @Profile("rest-api") @Tag(name = "User Management") @RequestMapping("/api/v1/admin") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) @Authority.HttpAdmin class UserGroupManagementController( private var userGroupRepository: UserGroupRepository, diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/auth/AccessTokenLoginController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/auth/AccessTokenLoginController.kt new file mode 100644 index 00000000..bdbfdac5 --- /dev/null +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/auth/AccessTokenLoginController.kt @@ -0,0 +1,63 @@ +package ch.hevs.cloudio.cloud.restapi.auth + +import ch.hevs.cloudio.cloud.restapi.CloudioHttpExceptions +import ch.hevs.cloudio.cloud.security.AccessTokenManager +import ch.hevs.cloudio.cloud.security.Authority +import ch.hevs.cloudio.cloud.security.CloudioUserDetails +import ch.hevs.cloudio.cloud.security.CloudioUserDetailsService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.apache.juli.logging.LogFactory +import org.springframework.context.annotation.Profile +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.web.bind.annotation.* + +@RestController +@Profile("rest-api") +@Tag(name = "Login", description = "Generates access token for the given user.") +@RequestMapping("/api/v1/auth") +class AccessTokenLoginController( + private val accessTokenManager: AccessTokenManager, + private val userDetailsService: CloudioUserDetailsService, + private val passwordEncoder: PasswordEncoder +) { + private val log = LogFactory.getLog(AccessTokenLoginController::class.java) + + @PostMapping("/login", consumes = [MediaType.APPLICATION_JSON_VALUE], produces = [MediaType.TEXT_PLAIN_VALUE]) + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "Authenticates user and generates an access token for the user.") + @ApiResponses( + value = [ + ApiResponse(description = "Access token generated.", responseCode = "200", content = [Content(schema = Schema(type = "string", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyIiwidWlkIjoyMzQyLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUyNjIzOTAyMn0.nt_g2tRx2sAUYn1p94S2nsbHVpX8CUU6oNSQ19TApC8"))]), + ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) + ] + ) + fun login( + @RequestBody @Parameter(description = "User's credentials.") request: UserLoginCredentials + ): String = userDetailsService.loadUserByUsername(request.username).let { + when { + !it.isAccountNonExpired -> { + log.info("Token refused for user \"${request.username}\" using password authentication - User is banned.") + throw CloudioHttpExceptions.Forbidden("User is banned.") + } + !it.authorities.contains(SimpleGrantedAuthority(Authority.HTTP_ACCESS.toString())) -> { + log.info("Token refused for user \"${request.username}\" using password authentication - User is missing HTTP_ACCESS role.") + throw CloudioHttpExceptions.Forbidden("No HTTP access.") + } + !passwordEncoder.matches(request.password, it.password) -> { + log.info("Token refused for user \"${request.username}\" using password authentication - Password is incorrect.") + throw CloudioHttpExceptions.Forbidden("Wrong password.") + } + else -> accessTokenManager.generateUserAccessToken(it as CloudioUserDetails) + } + } +} diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/auth/UserLoginCredentials.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/auth/UserLoginCredentials.kt new file mode 100644 index 00000000..1cd7ed6e --- /dev/null +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/auth/UserLoginCredentials.kt @@ -0,0 +1,12 @@ +package ch.hevs.cloudio.cloud.restapi.auth + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(name = "UserLoginCredentials", description = "User credentials for access token generation.") +data class UserLoginCredentials( + @Schema(description = "Username.", example = "john.doe") + val username: String, + + @Schema(description = "User's password.", example = "--> SECRET <--") + val password: String +) diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/data/EndpointDataAccessController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/data/EndpointDataAccessController.kt index 233caecd..33a57696 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/data/EndpointDataAccessController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/data/EndpointDataAccessController.kt @@ -7,9 +7,7 @@ import ch.hevs.cloudio.cloud.extension.fillFromInfluxDB import ch.hevs.cloudio.cloud.extension.userDetails import ch.hevs.cloudio.cloud.model.* import ch.hevs.cloudio.cloud.restapi.CloudioHttpExceptions -import ch.hevs.cloudio.cloud.security.CloudioPermissionManager -import ch.hevs.cloudio.cloud.security.EndpointModelElementPermission -import ch.hevs.cloudio.cloud.security.EndpointPermission +import ch.hevs.cloudio.cloud.security.* import ch.hevs.cloudio.cloud.serialization.SerializationFormat import ch.hevs.cloudio.cloud.serialization.fromIdentifiers import io.swagger.v3.oas.annotations.Operation @@ -19,13 +17,15 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.influxdb.InfluxDB import org.springframework.amqp.rabbit.core.RabbitTemplate import org.springframework.context.annotation.Profile import org.springframework.http.HttpStatus import org.springframework.http.MediaType -import org.springframework.security.core.Authentication +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext import org.springframework.util.AntPathMatcher import org.springframework.web.bind.annotation.* import javax.servlet.http.HttpServletRequest @@ -34,7 +34,12 @@ import javax.servlet.http.HttpServletRequest @Profile("rest-api") @Tag(name = "Endpoint Data Access", description = "Allows an user to access data of endpoints.") @RequestMapping("/api/v1/data") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements( + value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") + ] +) class EndpointDataAccessController( private val endpointRepository: EndpointRepository, private val permissionManager: CloudioPermissionManager, @@ -57,7 +62,7 @@ class EndpointDataAccessController( ] ) fun getModelElement( - @Parameter(hidden = true) authentication: Authentication, + @Parameter(hidden = true) @CurrentSecurityContext context: SecurityContext, @Parameter(hidden = true) request: HttpServletRequest ): Any { @@ -73,7 +78,17 @@ class EndpointDataAccessController( } // Resolve the access level the user has to the endpoint and fail if the user has no access to the endpoint. - val endpointPermission = permissionManager.resolveEndpointPermission(authentication.userDetails(), modelIdentifier.endpoint) + val authentication = context.authentication + val endpointPermission = when (authentication.principal) { + is CloudioUserDetails -> permissionManager.resolveEndpointPermission(authentication.userDetails(), modelIdentifier.endpoint) + is AccessTokenManager.ValidEndpointPermissionToken, is AccessTokenManager.ValidEndpointGroupPermissionToken -> + if (permissionManager.hasPermission(authentication, modelIdentifier.endpoint, EndpointPermission.READ)) { + EndpointPermission.READ + } else { + EndpointPermission.DENY + } + else -> EndpointPermission.DENY + } if (!endpointPermission.fulfills(EndpointPermission.ACCESS)) { throw CloudioHttpExceptions.Forbidden("Forbidden.") } @@ -143,17 +158,21 @@ class EndpointDataAccessController( @PutMapping("/**") @ResponseStatus(HttpStatus.NO_CONTENT) @Operation(summary = "Write access to all endpoint's data model.") - @ApiResponses(value = [ - ApiResponse(description = "Endpoint data was written to the given path.", responseCode = "204", content = [Content()]), - ApiResponse(description = "Invalid model identifier, attribute is not a SetPoint, nor a Parameter or element is not an attribute.", - responseCode = "400", content = [Content()]), - ApiResponse(description = "Endpoint not found or model element not found.", responseCode = "404", content = [Content()]), - ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]), - ApiResponse(description = "Invalid datatype or endpoint does not support any serialization format.", responseCode = "500", content = [Content()]), - ]) + @ApiResponses( + value = [ + ApiResponse(description = "Endpoint data was written to the given path.", responseCode = "204", content = [Content()]), + ApiResponse( + description = "Invalid model identifier, attribute is not a SetPoint, nor a Parameter or element is not an attribute.", + responseCode = "400", content = [Content()] + ), + ApiResponse(description = "Endpoint not found or model element not found.", responseCode = "404", content = [Content()]), + ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]), + ApiResponse(description = "Invalid datatype or endpoint does not support any serialization format.", responseCode = "500", content = [Content()]), + ] + ) fun putAttribute( @RequestParam @Parameter(description = "Value to set.") value: String, - @Parameter(hidden = true) authentication: Authentication, + @Parameter(hidden = true) @CurrentSecurityContext context: SecurityContext, @Parameter(hidden = true) request: HttpServletRequest ) { @@ -166,7 +185,21 @@ class EndpointDataAccessController( modelIdentifier.action = ActionIdentifier.ATTRIBUTE_SET // Resolve the access level the user has to the element. - if (!permissionManager.hasEndpointModelElementPermission(authentication.userDetails(), modelIdentifier, EndpointModelElementPermission.WRITE)) { + val authentication = context.authentication + val endpointPermission = when (authentication.principal) { + is CloudioUserDetails -> permissionManager.resolveEndpointPermission(authentication.userDetails(), modelIdentifier.endpoint) + is AccessTokenManager.ValidEndpointPermissionToken, is AccessTokenManager.ValidEndpointGroupPermissionToken -> + if (permissionManager.hasPermission(authentication, modelIdentifier.endpoint, EndpointPermission.WRITE)) { + EndpointPermission.WRITE + } else { + EndpointPermission.DENY + } + + else -> EndpointPermission.DENY + } + if (!endpointPermission.fulfills(EndpointPermission.WRITE) && + !permissionManager.hasEndpointModelElementPermission(authentication.userDetails(), modelIdentifier, EndpointModelElementPermission.WRITE) + ) { throw CloudioHttpExceptions.Forbidden("Forbidden.") } diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/data/EndpointWOTAccessController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/data/EndpointWOTAccessController.kt index 80321a94..9853ea08 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/data/EndpointWOTAccessController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/data/EndpointWOTAccessController.kt @@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.influxdb.InfluxDB import org.springframework.context.annotation.Profile @@ -34,7 +35,10 @@ import javax.servlet.http.HttpServletRequest @Profile("rest-api") @Tag(name = "Endpoint Data Access") @RequestMapping("/api/v1/wot") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) class EndpointWOTAccessController(private val endpointRepository: EndpointRepository, private val permissionManager: CloudioPermissionManager, private val influxDB: InfluxDB, @@ -53,7 +57,7 @@ class EndpointWOTAccessController(private val endpointRepository: EndpointReposi ] ) fun getModelElement( - @Parameter(hidden = true) authentication: Authentication, + @Parameter(hidden = true) authentication: Authentication, // TODO: Handle token based auth @Parameter(hidden = true) request: HttpServletRequest ): Any { diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/group/EndpointGroupManagementController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/group/EndpointGroupManagementController.kt index 2079ffba..4e57eaa4 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/group/EndpointGroupManagementController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/group/EndpointGroupManagementController.kt @@ -10,6 +10,7 @@ import ch.hevs.cloudio.cloud.security.EndpointPermission import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.context.annotation.Profile import org.springframework.http.HttpStatus @@ -22,7 +23,10 @@ import javax.transaction.Transactional @Profile("rest-api") @Tag(name = "Endpoint Group Management", description = "Allows a user to manage endpoint groups.") @RequestMapping("/api/v1/endpoints") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) class EndpointGroupManagementController( private var endpointGroupRepository: EndpointGroupRepository, private var endpointRepository: EndpointRepository, @@ -34,8 +38,9 @@ class EndpointGroupManagementController( @GetMapping("/groups") @ResponseStatus(HttpStatus.OK) fun getAllGroups( - @Parameter(hidden = true) authentication: Authentication - ) = permissionManager.resolveEndpointGroupsPermissions(authentication.userDetails()).mapNotNull { perm -> + @Parameter(hidden = true) authentication: Authentication? + ) = if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + else permissionManager.resolveEndpointGroupsPermissions(authentication.userDetails()).mapNotNull { perm -> endpointGroupRepository.findById(perm.endpointGroupID).orElse(null)?.let { EndpointGroupListEntity( name = it.groupName, @@ -49,7 +54,9 @@ class EndpointGroupManagementController( @ResponseStatus(HttpStatus.NO_CONTENT) @Authority.HttpEndpointCreation @Transactional - fun createGroup(@RequestBody body: EndpointGroupEntity, @Parameter(hidden = true) authentication: Authentication) { + fun createGroup(@RequestBody body: EndpointGroupEntity, @Parameter(hidden = true) authentication: Authentication?) { + if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + if (endpointGroupRepository.existsByGroupName(body.name)) { throw CloudioHttpExceptions.Conflict("Group '${body.name}' exists.") } @@ -77,8 +84,9 @@ class EndpointGroupManagementController( @PreAuthorize("hasPermission(#endpointGroupName, \"EndpointGroup\",T(ch.hevs.cloudio.cloud.security.EndpointPermission).ACCESS)") fun getGroupByGroupName( @PathVariable endpointGroupName: String, - @Parameter(hidden = true) authentication: Authentication - ) = endpointGroupRepository.findByGroupName(endpointGroupName).orElseThrow { + @Parameter(hidden = true) authentication: Authentication? + ) = if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + else endpointGroupRepository.findByGroupName(endpointGroupName).orElseThrow { CloudioHttpExceptions.NotFound("Group '$endpointGroupName' not found.") }.run { EndpointGroupEntity( @@ -103,9 +111,11 @@ class EndpointGroupManagementController( fun updateGroupByGroupName( @PathVariable endpointGroupName: String, @RequestBody body: EndpointGroupEntity, - @Parameter(hidden = true) authentication: Authentication + @Parameter(hidden = true) authentication: Authentication? ) { + if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + val endpointGroup = endpointGroupRepository.findByGroupName(endpointGroupName).orElseThrow { throw CloudioHttpExceptions.NotFound("Endpoint group not found.") } @@ -135,8 +145,13 @@ class EndpointGroupManagementController( @DeleteMapping("/groups/{endpointGroupName}") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional - fun deleteGroupByGroupName(@PathVariable endpointGroupName: String, @Parameter(hidden = true) authentication: Authentication) + fun deleteGroupByGroupName( + @PathVariable endpointGroupName: String, + @Parameter(hidden = true) authentication: Authentication? + ) { + if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + val endpointGroup = endpointGroupRepository.findByGroupName(endpointGroupName).orElseThrow { throw CloudioHttpExceptions.NotFound("Endpoint group not found.") } diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/history/EndpointHistoryAccessController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/history/EndpointHistoryAccessController.kt index 1d2533fd..211d6145 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/history/EndpointHistoryAccessController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/history/EndpointHistoryAccessController.kt @@ -6,9 +6,7 @@ import ch.hevs.cloudio.cloud.extension.userDetails import ch.hevs.cloudio.cloud.model.ActionIdentifier import ch.hevs.cloudio.cloud.model.ModelIdentifier import ch.hevs.cloudio.cloud.restapi.CloudioHttpExceptions -import ch.hevs.cloudio.cloud.security.CloudioPermissionManager -import ch.hevs.cloudio.cloud.security.EndpointModelElementPermission -import ch.hevs.cloudio.cloud.security.EndpointPermission +import ch.hevs.cloudio.cloud.security.* import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.media.ArraySchema @@ -17,6 +15,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.influxdb.InfluxDB import org.influxdb.dto.Query @@ -24,7 +23,8 @@ import org.influxdb.dto.QueryResult import org.springframework.context.annotation.Profile import org.springframework.http.HttpStatus import org.springframework.http.MediaType -import org.springframework.security.core.Authentication +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext import org.springframework.util.AntPathMatcher import org.springframework.web.bind.annotation.* import java.text.SimpleDateFormat @@ -35,12 +35,18 @@ import javax.servlet.http.HttpServletRequest @Profile("rest-api") @Tag(name = "Endpoint History Access", description = "Allows an user to access time series data of endpoints.") @RequestMapping("/api/v1/history") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements( + value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") + ] +) class EndpointHistoryAccessController( private val endpointRepository: EndpointRepository, private val permissionManager: CloudioPermissionManager, private val influx: InfluxDB, - private val influxProperties: CloudioInfluxProperties + private val influxProperties: CloudioInfluxProperties, + private val userDetailsService: CloudioUserDetailsService ) { private val antMatcher = AntPathMatcher() @@ -61,7 +67,7 @@ class EndpointHistoryAccessController( ] ) fun getModelElement( - @Parameter(hidden = true) authentication: Authentication, + @Parameter(hidden = true) @CurrentSecurityContext context: SecurityContext, @RequestParam @Parameter(description = "Optional start date (UTC) in the format 'yyyy-MM-ddTHH:mm:ss'. Default is to no start date (all data).", required = false) from: String?, @RequestParam @Parameter(description = "Optional end date (UTC) in the format 'yyyy-MM-ddTHH:mm:ss'. Default is to no end date (all data).", required = false) to: String?, @RequestParam @Parameter( @@ -91,13 +97,24 @@ class EndpointHistoryAccessController( throw CloudioHttpExceptions.NotFound("Endpoint not found.") } - // Check if user has READ access on the endpoint - if(!permissionManager.resolveEndpointPermission(authentication.userDetails(), modelIdentifier.endpoint) + val authentication = context.authentication + when (authentication.principal) { + is CloudioUserDetails -> { + // Check if user has READ access to the endpoint + if (!permissionManager.resolveEndpointPermission(authentication.userDetails(), modelIdentifier.endpoint) .fulfills(EndpointPermission.READ)) - { - // Check if user has access to the attribute. - if (!permissionManager.hasEndpointModelElementPermission(authentication.userDetails(), modelIdentifier, EndpointModelElementPermission.READ)) { - throw CloudioHttpExceptions.Forbidden("Forbidden.") + { + // Check if user has access to the attribute. + if (!permissionManager.hasEndpointModelElementPermission(authentication.userDetails(), modelIdentifier, EndpointModelElementPermission.READ)) { + throw CloudioHttpExceptions.Forbidden("Forbidden.") + } + } + } + + is AccessTokenManager.ValidEndpointPermissionToken, is AccessTokenManager.ValidEndpointGroupPermissionToken -> { + if (!permissionManager.hasPermission(authentication, modelIdentifier.endpoint, EndpointPermission.READ)) { + throw CloudioHttpExceptions.Forbidden("Forbidden.") + } } } @@ -126,7 +143,7 @@ class EndpointHistoryAccessController( ] ) fun getModelElementAsCSV( - @Parameter(hidden = true) authentication: Authentication, + @Parameter(hidden = true) @CurrentSecurityContext context: SecurityContext, @RequestParam @Parameter(description = "Optional start date (UTC) in the format 'yyyy-MM-ddTHH:mm:ss'. Default is to no start date (all data).", required = false) from: String?, @RequestParam @Parameter(description = "Optional end date (UTC) in the format 'yyyy-MM-ddTHH:mm:ss'. Default is to no end date (all data).", required = false) to: String?, @RequestParam @Parameter( @@ -157,13 +174,24 @@ class EndpointHistoryAccessController( throw CloudioHttpExceptions.NotFound("Endpoint not found.") } - // Check if user has READ access on the endpoint - if(!permissionManager.resolveEndpointPermission(authentication.userDetails(), modelIdentifier.endpoint) + val authentication = context.authentication + when (authentication.principal) { + is CloudioUserDetails -> { + // Check if user has READ access to the endpoint + if (!permissionManager.resolveEndpointPermission(authentication.userDetails(), modelIdentifier.endpoint) .fulfills(EndpointPermission.READ)) - { - // Check if user has access to the attribute. - if (!permissionManager.hasEndpointModelElementPermission(authentication.userDetails(), modelIdentifier, EndpointModelElementPermission.READ)) { - throw CloudioHttpExceptions.Forbidden("Forbidden.") + { + // Check if user has access to the attribute. + if (!permissionManager.hasEndpointModelElementPermission(authentication.userDetails(), modelIdentifier, EndpointModelElementPermission.READ)) { + throw CloudioHttpExceptions.Forbidden("Forbidden.") + } + } + } + + is AccessTokenManager.ValidEndpointPermissionToken, is AccessTokenManager.ValidEndpointGroupPermissionToken -> { + if (!permissionManager.hasPermission(authentication, modelIdentifier.endpoint, EndpointPermission.READ)) { + throw CloudioHttpExceptions.Forbidden("Forbidden.") + } } } diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/jobs/EndpointJobsController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/jobs/EndpointJobsController.kt index efb341fc..c53c028e 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/jobs/EndpointJobsController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/jobs/EndpointJobsController.kt @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.apache.juli.logging.LogFactory import org.springframework.amqp.rabbit.core.RabbitTemplate @@ -25,7 +26,10 @@ import java.util.* @Profile("rest-api") @Tag(name = "Endpoint remote job execution", description = "Run job (command or script) remotely on endpoints.") @RequestMapping("/api/v1/endpoints") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) class EndpointJobsController( private val endpointRepository: EndpointRepository, private val serializationFormats: Collection, diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/log/EndpointLogController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/log/EndpointLogController.kt index 3d5caa67..e5b148bf 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/log/EndpointLogController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/log/EndpointLogController.kt @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.influxdb.InfluxDB import org.influxdb.dto.Query @@ -26,7 +27,10 @@ import java.util.* @Profile("rest-api") @Tag(name = "Endpoint Log Access", description = "Access log output of an endpoint.") @RequestMapping("/api/v1/endpoints") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) class EndpointLogController( private val influx: InfluxDB, private val influxProperties: CloudioInfluxProperties diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/management/EndpointManagementController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/management/EndpointManagementController.kt index 3da18cf4..985f53b2 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/management/EndpointManagementController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/management/EndpointManagementController.kt @@ -17,6 +17,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.amqp.rabbit.core.RabbitTemplate import org.springframework.context.annotation.Profile @@ -32,7 +33,10 @@ import java.util.* @Profile("rest-api") @Tag(name = "Endpoint Management", description = "Allows users to list and manage their endpoints.") @RequestMapping("/api/v1/endpoints") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) class EndpointManagementController( private val endpointRepository: EndpointRepository, private val permissionManager: CloudioPermissionManager, @@ -57,11 +61,12 @@ class EndpointManagementController( ] ) fun getAllAccessibleEndpoints( - @Parameter(hidden = true) authentication: Authentication, + @Parameter(hidden = true) authentication: Authentication?, @RequestParam(required = false) @Parameter(description = "If given the list is filtered by the given friendly name.") friendlyName: String?, @RequestParam(required = false) @Parameter(description = "If given the list is filtered by the given banned status.") banned: Boolean?, @RequestParam(required = false) @Parameter(description = "If given the list is filtered by the given online status.") online: Boolean? - ) = permissionManager.resolvePermissions(authentication.userDetails()).mapNotNull { perm -> // TODO: Maybe resolveEndpointPermission() would be better in this case. + ) = if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + else permissionManager.resolvePermissions(authentication.userDetails()).mapNotNull { perm -> // TODO: Maybe resolveEndpointPermission() would be better in this case. endpointRepository.findById(perm.endpointUUID).orElse(null)?.let { when { !friendlyName.isNullOrEmpty() && it.friendlyName != friendlyName -> null @@ -287,8 +292,9 @@ class EndpointManagementController( ) fun postEndpointByFriendlyName( @RequestParam @Parameter(description = "Name of the endpoint.", schema = Schema(type = "string", defaultValue = "New endpoint", example = "My endpoint")) friendlyName: String = "New Endpoint", - @Parameter(hidden = true) authentication: Authentication - ) = authentication.userDetails().let { user -> + @Parameter(hidden = true) authentication: Authentication? + ) = if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + else authentication.userDetails().let { user -> val endpoint = endpointRepository.save( Endpoint( friendlyName = friendlyName diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/permission/EndpointGroupPermissionController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/permission/EndpointGroupPermissionController.kt index b3fc6fb0..b1d2fe1e 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/permission/EndpointGroupPermissionController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/permission/EndpointGroupPermissionController.kt @@ -3,6 +3,7 @@ package ch.hevs.cloudio.cloud.restapi.endpoint.permission import ch.hevs.cloudio.cloud.dao.* import ch.hevs.cloudio.cloud.extension.userDetails import ch.hevs.cloudio.cloud.restapi.CloudioHttpExceptions +import ch.hevs.cloudio.cloud.security.AccessTokenManager import ch.hevs.cloudio.cloud.security.EndpointModelElementPermission import ch.hevs.cloudio.cloud.security.EndpointPermission import io.swagger.v3.oas.annotations.Operation @@ -12,19 +13,26 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.context.annotation.Profile 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.util.AntPathMatcher import org.springframework.web.bind.annotation.* +import java.util.* import javax.servlet.http.HttpServletRequest @RestController @Profile("rest-api") @Tag(name = "Endpoint Permissions") @RequestMapping("/api/v1/endpoints/groups") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) class EndpointGroupPermissionController( private val userRepository: UserRepository, private val userEndpointGroupPermissionRepository: UserEndpointGroupPermissionRepository, @@ -32,7 +40,8 @@ class EndpointGroupPermissionController( private val userEndpointGroupModelElementPermissionRepository: UserEndpointGroupPermissionRepository, private val userGroupRepository: UserGroupRepository, private val userGroupEndpointGroupPermissionRepository: UserGroupEndpointGroupPermissionRepository, - private val userGroupEndpointGroupModelElementPermissionRepository: UserGroupEndpointGroupPermissionRepository + private val userGroupEndpointGroupModelElementPermissionRepository: UserGroupEndpointGroupPermissionRepository, + private val accessTokenManager: AccessTokenManager ) { private val antMatcher = AntPathMatcher() @@ -48,7 +57,7 @@ class EndpointGroupPermissionController( ] ) fun grantPermissionByEndpointGroup( - @Parameter(hidden = true) authentication: Authentication, + @Parameter(hidden = true) authentication: Authentication?, @PathVariable @Parameter(description = "The endpoint group name.", required = true) endpointGroupName: String, @RequestParam @Parameter(description = "User name to grant the permission to.", required = false) userName: String?, @RequestParam @Parameter(description = "User group name to grant the permission to.", required = false) userGroupName: String?, @@ -57,6 +66,8 @@ class EndpointGroupPermissionController( schema = Schema(allowableValues = ["DENY", "ACCESS", "BROWSE", "READ", "WRITE", "CONFIGURE", "GRANT"]) ) permission: EndpointPermission ) { + if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + if (permission == EndpointPermission.OWN) { throw CloudioHttpExceptions.Forbidden("OWN permission can not be granted.") } @@ -113,6 +124,45 @@ class EndpointGroupPermissionController( } } + @GetMapping("/{endpointGroupName}/token", produces = [MediaType.TEXT_PLAIN_VALUE]) + @ResponseStatus(HttpStatus.OK) + @PreAuthorize("hasPermission(#endpointGroupName, \"EndpointGroup\",T(ch.hevs.cloudio.cloud.security.EndpointPermission).GRANT)") + @Operation(summary = "Generate an access token for the given endpoint group.") + @ApiResponses( + value = [ + ApiResponse( + description = "Access token generated.", responseCode = "200", content = [Content( + schema = Schema( + type = "string", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlbmRwb2ludCIsInV1aWQiOiI4ZGQwZjgzNS1jZjQwLTRiM2YtYjRmYi0xMDhiMDBjYmI2ZWMiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUyNjIzOTAyMn0.dPzU5suQ_UKpbeQcXbtIbPahZK04tEa4DOxdE1zc3ew" + ) + )] + ), + ApiResponse(description = "Token can only be generated for READ, WRITE and CONFIGURE permission.", responseCode = "400", content = [Content()]), + ApiResponse(description = "User Group not found.", responseCode = "404", content = [Content()]), + ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) + ] + ) + fun getAccessTokenByGroupName( + @PathVariable @Parameter(description = "Name of the endpoint group.", required = true) endpointGroupName: String, + @RequestParam @Parameter(description = "Permission to grant.", schema = Schema(allowableValues = ["READ", "WRITE", "CONFIGURE"])) permission: EndpointPermission, + @RequestParam @Parameter( + description = "Expiration date and time for the token in ISO-8601 format (yyyy-MM-dd HH:mm:ss).", + schema = Schema(type = "string", example = "2042-01-01 07:15:00") + ) expires: Date, + @Parameter(hidden = true) authentication: Authentication? + ) = authentication?.let { + when (permission) { + EndpointPermission.READ, EndpointPermission.WRITE, EndpointPermission.CONFIGURE -> endpointGroupRepository.findByGroupName(endpointGroupName).orElseThrow { + throw CloudioHttpExceptions.NotFound("User Group not found.") + }.let { endpointGroup -> + accessTokenManager.generateEndpointGroupPermissionAccessToken(it.userDetails(), endpointGroup.groupName, permission, expires) + } + else -> throw CloudioHttpExceptions.BadRequest("Token can only be generated for READ, WRITE and CONFIGURE permission.") + } + } ?: throw CloudioHttpExceptions.Forbidden("User not found.") + + @PutMapping("/{endpointGroupName}/grant/**") @ResponseStatus(HttpStatus.NO_CONTENT) @Operation(summary = "Grant permission to element of endpoint group's data model to another user or user group.") @@ -125,13 +175,15 @@ class EndpointGroupPermissionController( ] ) fun grantModelPermissionByUUID( - @Parameter(hidden = true) authentication: Authentication, + @Parameter(hidden = true) authentication: Authentication?, @PathVariable @Parameter(description = "The endpoint group name.", required = true) endpointGroupName: String, @RequestParam @Parameter(description = "User name to grant the permission to.", required = false) userName: String?, @RequestParam @Parameter(description = "Group name to grant the permission to.", required = false) userGroupName: String?, @RequestParam @Parameter(description = "Permission to grant.") permission: EndpointModelElementPermission, @Parameter(hidden = true) request: HttpServletRequest ) { + if (authentication == null) throw CloudioHttpExceptions.Forbidden("No user.") + val endpointGroup = endpointGroupRepository.findByGroupName(endpointGroupName).orElseThrow { throw CloudioHttpExceptions.NotFound("Endpoint group not found.") } diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/permission/EndpointPermissionController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/permission/EndpointPermissionController.kt index 92dd12d6..699397ed 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/permission/EndpointPermissionController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/permission/EndpointPermissionController.kt @@ -1,7 +1,9 @@ package ch.hevs.cloudio.cloud.restapi.endpoint.permission import ch.hevs.cloudio.cloud.dao.* +import ch.hevs.cloudio.cloud.extension.userDetails import ch.hevs.cloudio.cloud.restapi.CloudioHttpExceptions +import ch.hevs.cloudio.cloud.security.AccessTokenManager import ch.hevs.cloudio.cloud.security.EndpointModelElementPermission import ch.hevs.cloudio.cloud.security.EndpointPermission import io.swagger.v3.oas.annotations.Operation @@ -11,10 +13,13 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.context.annotation.Profile 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.util.AntPathMatcher import org.springframework.web.bind.annotation.* import java.util.* @@ -24,12 +29,16 @@ import javax.servlet.http.HttpServletRequest @Profile("rest-api") @Tag(name = "Endpoint Permissions", description = "Allows users to manage permissions for their owned endpoints or endpoints for which they have the GRANT permission.") @RequestMapping("/api/v1/endpoints") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) class EndpointPermissionController( private val userRepository: UserRepository, private val userGroupRepository: UserGroupRepository, private val userEndpointPermissionRepository: UserEndpointPermissionRepository, - private val userGroupEndpointPermissionRepository: UserGroupEndpointPermissionRepository + private val userGroupEndpointPermissionRepository: UserGroupEndpointPermissionRepository, + private val accessTokenManager: AccessTokenManager ) { private val antMatcher = AntPathMatcher() @@ -98,6 +107,30 @@ class EndpointPermissionController( else -> throw CloudioHttpExceptions.BadRequest("Either userName or groupName has to be provided.") } + @GetMapping("/{uuid}/token", produces = [MediaType.TEXT_PLAIN_VALUE]) + @ResponseStatus(HttpStatus.OK) + @PreAuthorize("hasPermission(#uuid,T(ch.hevs.cloudio.cloud.security.EndpointPermission).GRANT)") + @Operation(summary = "Generate an access token for the given endpoint.") + @ApiResponses( + value = [ + ApiResponse(description = "Access token generated.", responseCode = "200", content = [Content(schema = Schema(type = "string", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlbmRwb2ludCIsInV1aWQiOiI4ZGQwZjgzNS1jZjQwLTRiM2YtYjRmYi0xMDhiMDBjYmI2ZWMiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUyNjIzOTAyMn0.dPzU5suQ_UKpbeQcXbtIbPahZK04tEa4DOxdE1zc3ew"))]), + ApiResponse(description = "Token can only be generated for READ, WRITE and CONFIGURE permission.", responseCode = "400", content = [Content()]), + ApiResponse(description = "Forbidden.", responseCode = "403", content = [Content()]) + ] + ) + fun getAccessTokenByUUID( + @PathVariable @Parameter(description = "UUID of endpoint.", required = true) uuid: UUID, + @RequestParam @Parameter(description = "Permission to grant.", schema = Schema(allowableValues = ["READ", "WRITE", "CONFIGURE"])) permission: EndpointPermission, + @RequestParam @Parameter(description = "Expiration date and time for the token in ISO-8601 format (yyyy-MM-dd HH:mm:ss).", schema = Schema(type = "string", example = "2042-01-01 07:15:00")) expires: Date, + @Parameter(hidden = true) authentication: Authentication? + ) = authentication?.let { + when (permission) { + EndpointPermission.READ, EndpointPermission.WRITE, EndpointPermission.CONFIGURE -> accessTokenManager.generateEndpointPermissionAccessToken(it.userDetails(), uuid, permission, expires) + else -> throw CloudioHttpExceptions.BadRequest("Token can only be generated for READ, WRITE and CONFIGURE permission.") + } + } ?: throw CloudioHttpExceptions.Forbidden("User not found.") + @PutMapping("/{uuid}/grant/**") @ResponseStatus(HttpStatus.NO_CONTENT) @PreAuthorize("hasPermission(#uuid,T(ch.hevs.cloudio.cloud.security.EndpointPermission).GRANT)") diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/provisioning/EndpointProvisioningController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/provisioning/EndpointProvisioningController.kt index c9380b0b..e9feca74 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/provisioning/EndpointProvisioningController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/endpoint/provisioning/EndpointProvisioningController.kt @@ -2,11 +2,10 @@ package ch.hevs.cloudio.cloud.restapi.endpoint.provisioning import ch.hevs.cloudio.cloud.dao.Endpoint import ch.hevs.cloudio.cloud.dao.EndpointRepository -import ch.hevs.cloudio.cloud.dao.ProvisionToken -import ch.hevs.cloudio.cloud.dao.ProvisionTokenRepository import ch.hevs.cloudio.cloud.extension.* import ch.hevs.cloudio.cloud.internalservice.certificatemanager.CertificateManagerService import ch.hevs.cloudio.cloud.restapi.CloudioHttpExceptions +import ch.hevs.cloudio.cloud.security.AccessTokenManager import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.media.Content @@ -14,6 +13,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.context.annotation.Profile import org.springframework.http.HttpHeaders @@ -33,11 +33,16 @@ import javax.servlet.http.HttpServletRequest @Profile("rest-api") @Tag(name = "Endpoint Provisioning", description = "Allows users or developers to provision new endpoints into the system.") @RequestMapping("/api/v1") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements( + value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") + ] +) class EndpointProvisioningController( private val endpointRepository: EndpointRepository, - private val provisionTokenRepository: ProvisionTokenRepository, - private val certificateManager: CertificateManagerService + private val certificateManager: CertificateManagerService, + private val accessTokenManager: AccessTokenManager ) { private val caCertificate by lazy { certificateManager.getCACertificate() @@ -93,18 +98,7 @@ class EndpointProvisioningController( endpoint.configuration.properties.putAll(this) } - // Remove pending tokens for this endpoint. - provisionTokenRepository.deleteByEndpointUUID(uuid) - - // Add a token to the provision token database. - val expires = Date() - expires.time = System.currentTimeMillis() + 24 * 3600 * 1000 - return provisionTokenRepository.save( - ProvisionToken( - endpointUUID = uuid, - expires = expires - ) - ).token + return accessTokenManager.generateProvisionToken(uuid, 24 * 2600 * 1000) } @GetMapping("/endpoints/{uuid}/provision", produces = [MediaType.APPLICATION_JSON_VALUE, "application/java-archive", "application/zip"]) @@ -157,25 +151,26 @@ class EndpointProvisioningController( @RequestParam @Parameter(description = "Public key to use for certificate generation in PEM format.") publicKey: String?, @RequestParam @Parameter(description = "Name of the properties file. If not specified, the file will named according to the UUID of the endpoint.") propertiesFileName: String?, @Parameter(hidden = true) request: HttpServletRequest - ): ResponseEntity = provisionTokenRepository.findByToken(token).orElseThrow { - CloudioHttpExceptions.Forbidden("Forbidden") - }.let { - endpointRepository.findById(it.endpointUUID).orElseThrow { - CloudioHttpExceptions.NotFound("Endpoint not found") - }.getProvisionEntity( - when (request.getHeader("Accept")) { - "application/java-archive" -> EndpointProvisioningDataFormat.JAR_ARCHIVE - "application/zip" -> EndpointProvisioningDataFormat.ZIP_ARCHIVE - else -> EndpointProvisioningDataFormat.JSON - }, publicKey, propertiesFileName, it - ) + ): ResponseEntity = accessTokenManager.validate(token).let { result -> + when (result) { + is AccessTokenManager.ValidProvisioningToken -> endpointRepository.findById(result.endpointUUID).orElseThrow { + CloudioHttpExceptions.NotFound("Endpoint not found") + }.getProvisionEntity( + when (request.getHeader("Accept")) { + "application/java-archive" -> EndpointProvisioningDataFormat.JAR_ARCHIVE + "application/zip" -> EndpointProvisioningDataFormat.ZIP_ARCHIVE + else -> EndpointProvisioningDataFormat.JSON + }, publicKey, propertiesFileName + ) + + else -> throw CloudioHttpExceptions.Forbidden("Forbidden") + } } private fun Endpoint.getProvisionEntity( endpointProvisionDataFormat: EndpointProvisioningDataFormat?, publicKey: String?, - propertiesFileName: String? = null, - token: ProvisionToken? = null, + propertiesFileName: String? = null ): ResponseEntity = this.run { if (configuration.clientCertificate.isNotEmpty() && publicKey != null) { configuration.clientCertificate = "" @@ -198,9 +193,6 @@ class EndpointProvisioningController( } endpointRepository.save(this) - if (token != null) { - provisionTokenRepository.delete(token) - } when (endpointProvisionDataFormat ?: EndpointProvisioningDataFormat.JSON) { EndpointProvisioningDataFormat.JSON -> ResponseEntity.ok() @@ -261,6 +253,7 @@ class EndpointProvisioningController( .header("Endpoint", this.uuid.toString()) .body(output.toByteArray()) } + EndpointProvisioningDataFormat.ZIP_ARCHIVE -> { if (configuration.privateKey.isEmpty()) { CloudioHttpExceptions.BadRequest("Endpoint has no private key.") @@ -293,7 +286,7 @@ class EndpointProvisioningController( zip.write(certificate) zip.closeEntry() - if (configuration.privateKey.isNotEmpty()){ + if (configuration.privateKey.isNotEmpty()) { zip.putNextEntry(ZipEntry("${uuid}-private-key.pem")) zip.write(configuration.privateKey.toByteArray()) zip.closeEntry() @@ -303,10 +296,10 @@ class EndpointProvisioningController( zip.close() ResponseEntity.ok() - .contentType(MediaType.parseMediaType("application/zip")) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"${this.uuid}.zip\"") - .header("Endpoint", this.uuid.toString()) - .body(output.toByteArray()) + .contentType(MediaType.parseMediaType("application/zip")) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"${this.uuid}.zip\"") + .header("Endpoint", this.uuid.toString()) + .body(output.toByteArray()) } } } diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/node/NodeController.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/node/NodeController.kt index 9ed82ddc..d444143e 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/node/NodeController.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/restapi/node/NodeController.kt @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.boot.info.BuildProperties import org.springframework.context.annotation.Profile @@ -26,7 +27,10 @@ import java.net.InetAddress @Tag(name = "Node info", description = "Information about the cloud.iO service node.") @RestController @RequestMapping("api/v1/node") -@SecurityRequirement(name = "basicAuth") +@SecurityRequirements(value = [ + SecurityRequirement(name = "basicAuth"), + SecurityRequirement(name = "tokenAuth") +]) class NodeController( private val buildProperties: BuildProperties ) { diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/security/AccessTokenFilter.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/security/AccessTokenFilter.kt new file mode 100644 index 00000000..01282079 --- /dev/null +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/security/AccessTokenFilter.kt @@ -0,0 +1,51 @@ +package ch.hevs.cloudio.cloud.security + +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +@Component +class AccessTokenFilter( + private val accessTokenManager: AccessTokenManager, +) : OncePerRequestFilter() { + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { + request.getHeader("Authorization").let { header -> + if (header != null && header.isNotEmpty() && header.startsWith("Bearer")) { + header.split(" ").getOrNull(1)?.trim()?.let { token -> + accessTokenManager.validate(token).let { result -> + try { + when (result) { + is AccessTokenManager.ValidUserToken -> SecurityContextHolder.getContext().authentication = + UsernamePasswordAuthenticationToken(result.userDetails, null, result.userDetails.authorities).apply { + details = WebAuthenticationDetailsSource().buildDetails(request) + } + + is AccessTokenManager.ValidEndpointPermissionToken -> SecurityContextHolder.getContext().authentication = + AnonymousAuthenticationToken(result.hashCode().toString(), result, listOf(Authority.HTTP_ACCESS).map { SimpleGrantedAuthority(it.name) }).apply { + details = WebAuthenticationDetailsSource().buildDetails(request) + } + + + is AccessTokenManager.ValidEndpointGroupPermissionToken -> SecurityContextHolder.getContext().authentication = + AnonymousAuthenticationToken(result.hashCode().toString(), result, listOf(Authority.HTTP_ACCESS).map { SimpleGrantedAuthority(it.name) }).apply { + details = WebAuthenticationDetailsSource().buildDetails(request) + } + + else -> {} + } + } catch (_: Exception) { } + } + } + } + } + + filterChain.doFilter(request, response) + } +} diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/security/AccessTokenManager.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/security/AccessTokenManager.kt new file mode 100644 index 00000000..e6b3cfe1 --- /dev/null +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/security/AccessTokenManager.kt @@ -0,0 +1,141 @@ +package ch.hevs.cloudio.cloud.security + +import io.jsonwebtoken.* +import org.apache.juli.logging.LogFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.stereotype.Service +import java.util.* +import javax.annotation.PostConstruct + +@Service +class AccessTokenManager( + private val userDetailsService: CloudioUserDetailsService, + private val permissionManager: CloudioPermissionManager +) { + sealed interface ValidationResult + object InvalidToken: ValidationResult + class ValidProvisioningToken(val endpointUUID: UUID): ValidationResult + class ValidUserToken(val userDetails: CloudioUserDetails): ValidationResult + data class ValidEndpointPermissionToken(val endpointUUID: UUID, val permission: EndpointPermission): ValidationResult + data class ValidEndpointGroupPermissionToken(val groupName: String, val permission: EndpointPermission): ValidationResult + + private val log = LogFactory.getLog(AccessTokenManager::class.java) + + @Value("\${cloudio.jwt.secret:#{null}}") + private var secretKey: String? = null + + @Value("\${cloudio.jwt.expirationDuration:#{24 * 60 * 60 * 1000}}") + private var tokenExpirationDuration: Long = 24 * 60 * 60 * 1000 + + @PostConstruct + private fun generateSecretKeyIfMissing() { + if (secretKey == null) { + log.error("JWT token secret key (cloudio.jwt.secret) is missing, a key will be generated. Tokens will only be valid for this node and not across restarts!") + secretKey = ByteArray(64).let { + Random(System.currentTimeMillis()).nextBytes(it) + Base64.getEncoder().encodeToString(it) + } + } + } + + fun generateProvisionToken(endpointUUID: UUID, expirationDuration: Long = tokenExpirationDuration): String = Jwts.builder() + .setSubject("provision") + .claim("endpoint", endpointUUID.toString()) + .setIssuedAt(Date()) + .setExpiration(Date(System.currentTimeMillis() + expirationDuration)) + .signWith(SignatureAlgorithm.HS512, secretKey) + .compact() + + fun generateUserAccessToken(user: CloudioUserDetails): String = Jwts.builder() + .setSubject("user") + .claim("uid", user.id.toString()) + .setIssuedAt(Date()) + .setExpiration(Date(System.currentTimeMillis() + tokenExpirationDuration)) + .signWith(SignatureAlgorithm.HS512, secretKey) + .compact() + + fun generateEndpointPermissionAccessToken(user: CloudioUserDetails, endpointUUID: UUID, permission: EndpointPermission, expires: Date = Date(System.currentTimeMillis() + tokenExpirationDuration)): String = Jwts.builder() + .setSubject("endpoint") + .claim("uuid", endpointUUID.toString()) + .claim("perm", permission) + .setIssuer(user.id.toString()) + .setIssuedAt(Date()) + .setExpiration(expires) + .signWith(SignatureAlgorithm.HS512, secretKey) + .compact() + + fun generateEndpointGroupPermissionAccessToken(user: CloudioUserDetails, groupName: String, permission: EndpointPermission, expires: Date = Date(System.currentTimeMillis() + tokenExpirationDuration)): String = Jwts.builder() + .setSubject("endpoint-group") + .claim("group", groupName) + .claim("perm", permission) + .setIssuer(user.id.toString()) + .setIssuedAt(Date()) + .setExpiration(expires) + .signWith(SignatureAlgorithm.HS512, secretKey) + .compact() + + fun validate(token: String): ValidationResult { + try { + return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).let { claims -> + when (claims.body.subject) { + "provision" -> ValidProvisioningToken(UUID.fromString(claims.body["endpoint"] as String)) + + "user" -> ValidUserToken(userDetailsService.loadUserByID((claims.body["uid"] as String).toLong())) + + "endpoint" -> { + val endpointUUID = UUID.fromString(claims.body["uuid"] as String) + val permission = EndpointPermission.valueOf(claims.body["perm"] as String) + val userDetails = userDetailsService.loadUserByID(claims.body.issuer.toLong()) + + when { + permissionManager.hasEndpointPermission(userDetails, endpointUUID, EndpointPermission.GRANT) && + setOf(EndpointPermission.READ, EndpointPermission.WRITE, EndpointPermission.CONFIGURE).contains(permission) -> + ValidEndpointPermissionToken(UUID.fromString(claims.body["uuid"] as String), permission) + else -> { + log.warn("Token for endpoint access contains invalid permission or issues has no GRANT permission anymore") + InvalidToken + } + } + } + + "endpoint-group" -> { + val endpointGroupName = claims.body["group"] as String + val permission = EndpointPermission.valueOf(claims.body["perm"] as String) + val userDetails = userDetailsService.loadUserByID(claims.body.issuer.toLong()) + + when { + permissionManager.hasEndpointGroupPermission(userDetails, endpointGroupName, EndpointPermission.GRANT) && + setOf(EndpointPermission.READ, EndpointPermission.WRITE, EndpointPermission.CONFIGURE).contains(permission) -> + ValidEndpointGroupPermissionToken(endpointGroupName, permission) + else -> { + log.warn("Token for endpoint access contains invalid permission or issues has no GRANT permission anymore") + InvalidToken + } + } + } + else -> { + log.warn("Token contains invalid subject: ${claims.body.subject}") + InvalidToken + } + } + } + } catch (exception: ExpiredJwtException) { + log.warn("JWT expired") + } catch (exception: IllegalArgumentException) { + log.warn("Token is null, empty or only whitespace or endpoint token contains invalid UUID") + } catch (exception: MalformedJwtException) { + log.warn("JWT is invalid") + } catch (exception: UnsupportedJwtException) { + log.warn("JWT is not supported") + } catch (exception: SignatureException) { + log.warn("Signature validation failed") + } catch (exception: NumberFormatException) { + log.warn("Invalid user ID in token") + } catch (exception: UsernameNotFoundException) { + log.warn("User not found") + } + + return InvalidToken + } +} \ No newline at end of file diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/security/CloudioPermissionManager.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/security/CloudioPermissionManager.kt index dfb6f37c..9f0ad4ac 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/security/CloudioPermissionManager.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/security/CloudioPermissionManager.kt @@ -23,20 +23,40 @@ class CloudioPermissionManager( private val log = LogFactory.getLog(CloudioPermissionManager::class.java) override fun hasPermission(authentication: Authentication, subject: Any?, permission: Any?) = when { - subject is UUID && permission is EndpointPermission && authentication.principal is CloudioUserDetails -> hasEndpointPermission(authentication.principal as CloudioUserDetails, subject, permission) + subject is UUID && permission is EndpointPermission && authentication.principal is CloudioUserDetails -> + hasEndpointPermission(authentication.principal as CloudioUserDetails, subject, permission) + + subject is UUID && permission is EndpointPermission && authentication.principal is AccessTokenManager.ValidEndpointPermissionToken -> + (authentication.principal as AccessTokenManager.ValidEndpointPermissionToken).let { token -> + token.permission.fulfills(permission) && subject == token.endpointUUID + } + + subject is UUID && permission is EndpointPermission && authentication.principal is AccessTokenManager.ValidEndpointGroupPermissionToken -> + (authentication.principal as AccessTokenManager.ValidEndpointGroupPermissionToken).let { token -> + token.permission.fulfills(permission) && endpointGroupRepository.findByGroupName(token.groupName).let { group -> + group.isPresent && endpointRepository.findByGroupMembershipsContains(group.get()).any { + subject == it.uuid + } + } + } + subject is String && permission is EndpointPermission && authentication.principal is CloudioUserDetails -> try { hasEndpointPermission(authentication.principal as CloudioUserDetails, UUID.fromString(subject), permission) } catch (e: Exception) { log.error("Invalid endpoint UUID: Authentication = $authentication, subject = $subject, permission = $permission") false } - subject is ModelIdentifier && permission is EndpointModelElementPermission && authentication.principal is CloudioUserDetails -> hasEndpointModelElementPermission(authentication.principal as CloudioUserDetails, subject, permission) + + subject is ModelIdentifier && permission is EndpointModelElementPermission && authentication.principal is CloudioUserDetails -> + hasEndpointModelElementPermission(authentication.principal as CloudioUserDetails, subject, permission) + subject is String && permission is EndpointModelElementPermission && authentication.principal is CloudioUserDetails -> ModelIdentifier(subject).let { - if (it.valid) hasEndpointModelElementPermission(authentication.principal as CloudioUserDetails, it, permission) else { - log.error("Invalid model identifier: Authentication = $authentication, subject = $subject, permission = $permission") - false + if (it.valid) hasEndpointModelElementPermission(authentication.principal as CloudioUserDetails, it, permission) else { + log.error("Invalid model identifier: Authentication = $authentication, subject = $subject, permission = $permission") + false + } } - } + else -> { log.error("Unknown permission request: Authentication = $authentication, subject = $subject, permission = $permission") false @@ -44,9 +64,17 @@ class CloudioPermissionManager( } override fun hasPermission(authentication: Authentication, targetId: Serializable?, targetType: String?, permission: Any?) = when { - targetType == "Endpoint" && targetId is UUID && permission is EndpointPermission && authentication.principal is CloudioUserDetails -> hasEndpointPermission(authentication.principal as CloudioUserDetails, targetId, permission) - targetType == "Model" && targetId is ModelIdentifier && permission is EndpointModelElementPermission && authentication.principal is CloudioUserDetails -> hasEndpointModelElementPermission(authentication.principal as CloudioUserDetails, targetId, permission) - targetType == "EndpointGroup" && targetId is String && permission is EndpointPermission && authentication.principal is CloudioUserDetails -> hasEndpointGroupPermission(authentication.principal as CloudioUserDetails, targetId, permission) + targetType == "Endpoint" && targetId is UUID && permission is EndpointPermission && authentication.principal is CloudioUserDetails -> + hasEndpointPermission(authentication.principal as CloudioUserDetails, targetId, permission) + + targetType == "Model" && targetId is ModelIdentifier && permission is EndpointModelElementPermission && authentication.principal is CloudioUserDetails -> + hasEndpointModelElementPermission(authentication.principal as CloudioUserDetails, targetId, permission + ) + + targetType == "EndpointGroup" && targetId is String && permission is EndpointPermission && authentication.principal is CloudioUserDetails -> + hasEndpointGroupPermission(authentication.principal as CloudioUserDetails, targetId, permission + ) + else -> { log.error("Unknown permission request: Authentication = $authentication, targetId = $targetId, permission = $permission") false diff --git a/src/main/kotlin/ch/hevs/cloudio/cloud/security/CloudioUserDetailsService.kt b/src/main/kotlin/ch/hevs/cloudio/cloud/security/CloudioUserDetailsService.kt index 1345e363..d69ef869 100644 --- a/src/main/kotlin/ch/hevs/cloudio/cloud/security/CloudioUserDetailsService.kt +++ b/src/main/kotlin/ch/hevs/cloudio/cloud/security/CloudioUserDetailsService.kt @@ -23,12 +23,20 @@ class CloudioUserDetailsService( @Transactional override fun loadUserByUsername(userName: String): UserDetails = userRepository.findByUserName(userName).orElseThrow { - UsernameNotFoundException("User \"$userName\"not found.") + UsernameNotFoundException("User \"$userName\" not found.") }.let { user -> CloudioUserDetails(user.id, user.userName, user.password, user.banned, user.authorities.map(Authority::name).map { authority -> SimpleGrantedAuthority(authority) }, user.groupMemberships.map { it.id }) } + @Transactional + fun loadUserByID(id: Long): CloudioUserDetails = userRepository.findById(id).orElseThrow { + UsernameNotFoundException("User with ID $id not found.") + }.let { user -> + CloudioUserDetails(user.id, user.userName, user.password, user.banned, user.authorities.map(Authority::name).map { authority -> + SimpleGrantedAuthority(authority) + }, user.groupMemberships.map { it.id }) } + @Value(value = "\${cloudio.initialAdminPassword:#{null}}") private var initialAdminPassword: String? = null diff --git a/src/main/resources/application-rest-api.yml b/src/main/resources/application-rest-api.yml index 547c5210..f384ea92 100644 --- a/src/main/resources/application-rest-api.yml +++ b/src/main/resources/application-rest-api.yml @@ -1,3 +1,6 @@ spring: main: - web-application-type: servlet \ No newline at end of file + web-application-type: servlet + mvc: + format: + date: iso \ No newline at end of file diff --git a/src/test/kotlin/ch/hevs/cloudio/cloud/restapi/admin/UserGroupManagementControllerTests.kt b/src/test/kotlin/ch/hevs/cloudio/cloud/restapi/admin/UserGroupManagementControllerTests.kt index ab9128f3..a5b42334 100644 --- a/src/test/kotlin/ch/hevs/cloudio/cloud/restapi/admin/UserGroupManagementControllerTests.kt +++ b/src/test/kotlin/ch/hevs/cloudio/cloud/restapi/admin/UserGroupManagementControllerTests.kt @@ -126,7 +126,7 @@ class UserGroupManagementControllerTests { @WithMockUser("Admin", authorities = ["HTTP_ACCESS", "HTTP_ADMIN"]) fun modifyNonExistentGroup() { assertThrows { - userGroupManagementController.updateGroupByGroupName("TestGroup2", UserGroupEntity("TestGroup2", mapOf("test" to true))) + userGroupManagementController.updateGroupByGroupName("TestGroup2", UserGroupEntity("TestGroup2", mapOf("test" to true), emptyList())) } } @@ -134,7 +134,7 @@ class UserGroupManagementControllerTests { @WithMockUser("Admin", authorities = ["HTTP_ACCESS", "HTTP_ADMIN"]) fun modifyWhereNamesDoNotMatch() { assertThrows { - userGroupManagementController.updateGroupByGroupName("TestGroup", UserGroupEntity("TestGroup2", emptyMap())) + userGroupManagementController.updateGroupByGroupName("TestGroup", UserGroupEntity("TestGroup2", emptyMap(), emptyList())) } } @@ -142,7 +142,7 @@ class UserGroupManagementControllerTests { @WithMockUser("sepp.blatter", authorities = ["HTTP_ACCESS"]) fun modifyByNonAdmin() { assertThrows { - userGroupManagementController.updateGroupByGroupName("TestGroup", UserGroupEntity("TestGroup", mapOf("test" to true))) + userGroupManagementController.updateGroupByGroupName("TestGroup", UserGroupEntity("TestGroup", mapOf("test" to true), emptyList())) } } @@ -228,7 +228,7 @@ class UserGroupManagementControllerTests { } assertThrows { - userGroupManagementController.updateGroupByGroupName(randomCharacters, UserGroupEntity(randomCharacters, emptyMap())) + userGroupManagementController.updateGroupByGroupName(randomCharacters, UserGroupEntity(randomCharacters, emptyMap(), emptyList())) } assertThrows {