diff --git a/.editorconfig b/.editorconfig index 14efb1a..1de69b6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -17,6 +17,10 @@ tab_width = 2 [*.bat] end_of_line = crlf +[*.pem] +max_line_length = 64 +insert_final_newline = false + # noinspection EditorConfigKeyCorrectness [*.{kt,kts}] ij_kotlin_packages_to_use_import_on_demand = unset diff --git a/.gitignore b/.gitignore index 5a979af..004b1eb 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +#### Hlæja ### +/cert/ diff --git a/README.md b/README.md index 0ac45ab..553cd68 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,22 @@ In twilight's hush, where mythic tales unfold, A ledger of legends, the bravest | spring.r2dbc.url | ✓ | Postgres host url | | spring.r2dbc.username | ✓ | Postgres username | | spring.r2dbc.password | ✗ | Postgres password | +| jwt.private-key | ✓ | JWT private key file | *Required: ✓ can be stored as text, and ✗ need to be stored as secret.* -## Releasing Service +## Development Configuration Run `release.sh` script from `master` branch. ## Development Information +### Private RSA Key + +This service uses RAS keys to create identities for users. The private key is used here to generate identities, while the public key is used by **[Hlæja Registry API](https://github.com/swordsteel/hlaeja-registry-api)** to identify a users and accept data. + +*For instructions on how to set these up, please refer to our [generate RSA key](https://github.com/swordsteel/hlaeja-development/blob/master/doc/rsa_key.md) documentation.* + ### Global Setting This services rely on a set of global settings to configure development environments. These settings, managed through Gradle properties or environment variables. diff --git a/build.gradle.kts b/build.gradle.kts index 658acb2..5aac87b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(hlaeja.plugins.kotlin.jvm) alias(hlaeja.plugins.kotlin.spring) + alias(hlaeja.plugins.ltd.hlaeja.plugin.certificate) alias(hlaeja.plugins.ltd.hlaeja.plugin.service) alias(hlaeja.plugins.spring.dependency.management) alias(hlaeja.plugins.springframework.boot) @@ -11,8 +12,10 @@ dependencies { implementation(hlaeja.kotlin.reflect) implementation(hlaeja.kotlinx.coroutines) implementation(hlaeja.library.hlaeja.common.messages) + implementation(hlaeja.library.hlaeja.jwt) implementation(hlaeja.springboot.starter.actuator) implementation(hlaeja.springboot.starter.r2dbc) + implementation(hlaeja.springboot.starter.security) implementation(hlaeja.springboot.starter.webflux) runtimeOnly(hlaeja.postgresql) @@ -29,3 +32,7 @@ dependencies { } group = "ltd.hlaeja" + +tasks.named("processResources") { + dependsOn("copyCertificates") +} diff --git a/http/authentication.http b/http/authentication.http new file mode 100644 index 0000000..b7bb4b1 --- /dev/null +++ b/http/authentication.http @@ -0,0 +1,44 @@ +### Get admin information +POST {{hostname}}/authenticate +Content-Type: application/json + +{ + "username": "admin", + "password": "pass" +} + +### Get user information +POST {{hostname}}/authenticate +Content-Type: application/json + +{ + "username": "user", + "password": "pass" +} + +### Get bad user +POST {{hostname}}/authenticate +Content-Type: application/json + +{ + "username": "bad user", + "password": "pass" +} + +### Get bad pass +POST {{hostname}}/authenticate +Content-Type: application/json + +{ + "username": "user", + "password": "bad pass" +} + +### Get disabled user +POST {{hostname}}/authenticate +Content-Type: application/json + +{ + "username": "disabled", + "password": "pass" +} diff --git a/sql/test_001-account.sql b/sql/test_001-account.sql new file mode 100644 index 0000000..a4c10cd --- /dev/null +++ b/sql/test_001-account.sql @@ -0,0 +1,4 @@ +insert into public.accounts (id, created_at, updated_at, enabled, username, password, roles) +values ('00000000-0000-7000-0000-000000000001'::uuid, '2000-01-01 00:00:00.000001 +00:00', '2000-01-01 00:00:01.000001 +00:00', true, 'admin', '$2a$12$KoXBoLOANMK11J4xeJHPA.Sy0FG.m8KWk7P4XFsMO.ZbFmFI2DckK', 'ROLE_ADMIN'), + ('00000000-0000-7000-0000-000000000002'::uuid, '2000-01-01 00:00:00.000001 +00:00', '2000-01-01 00:00:01.000001 +00:00', true, 'user', '$2a$12$KoXBoLOANMK11J4xeJHPA.Sy0FG.m8KWk7P4XFsMO.ZbFmFI2DckK', 'ROLE_USER'), + ('00000000-0000-7000-0000-000000000003'::uuid, '2000-01-01 00:00:00.000001 +00:00', '2000-01-01 00:00:01.000001 +00:00', false, 'disabled', '$2a$12$KoXBoLOANMK11J4xeJHPA.Sy0FG.m8KWk7P4XFsMO.ZbFmFI2DckK', 'ROLE_USER'); diff --git a/src/main/kotlin/ltd/hlaeja/configuration/SecurityConfiguration.kt b/src/main/kotlin/ltd/hlaeja/configuration/SecurityConfiguration.kt new file mode 100644 index 0000000..643d33a --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/configuration/SecurityConfiguration.kt @@ -0,0 +1,47 @@ +package ltd.hlaeja.configuration + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity.AuthorizeExchangeSpec +import org.springframework.security.config.web.server.ServerHttpSecurity.CsrfSpec +import org.springframework.security.config.web.server.ServerHttpSecurity.FormLoginSpec +import org.springframework.security.config.web.server.ServerHttpSecurity.HttpBasicSpec +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.server.SecurityWebFilterChain + +@Configuration +@EnableWebFluxSecurity +class SecurityConfiguration { + + @Bean + fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() + + @Bean + fun securityWebFilterChain( + serverHttpSecurity: ServerHttpSecurity, + ): SecurityWebFilterChain = serverHttpSecurity + .authorizeExchange(::authorizeExchange) + .httpBasic(::httpBasic) + .formLogin(::formLogin) + .csrf(::csrf) + .build() + + private fun csrf( + csrf: CsrfSpec, + ) = csrf.disable() + + private fun formLogin( + formLogin: FormLoginSpec, + ) = formLogin.disable() + + private fun httpBasic( + httpBasic: HttpBasicSpec, + ) = httpBasic.disable() + + private fun authorizeExchange( + authorizeExchange: AuthorizeExchangeSpec, + ) = authorizeExchange.anyExchange().permitAll() +} diff --git a/src/main/kotlin/ltd/hlaeja/controller/AuthenticationController.kt b/src/main/kotlin/ltd/hlaeja/controller/AuthenticationController.kt new file mode 100644 index 0000000..ad85651 --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/controller/AuthenticationController.kt @@ -0,0 +1,20 @@ +package ltd.hlaeja.controller + +import ltd.hlaeja.library.accountRegistry.Authentication +import ltd.hlaeja.service.AuthenticationService +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono + +@RestController +class AuthenticationController( + private val authenticationService: AuthenticationService, +) { + + @PostMapping("/authenticate") + fun authenticate( + @RequestBody request: Authentication.Request, + ): Mono = authenticationService.authenticate(request.username, request.password) + .map { Authentication.Response(it) } +} diff --git a/src/main/kotlin/ltd/hlaeja/repository/AccountRepository.kt b/src/main/kotlin/ltd/hlaeja/repository/AccountRepository.kt index a1f528e..13ccadb 100644 --- a/src/main/kotlin/ltd/hlaeja/repository/AccountRepository.kt +++ b/src/main/kotlin/ltd/hlaeja/repository/AccountRepository.kt @@ -4,6 +4,9 @@ import java.util.UUID import ltd.hlaeja.entity.AccountEntity import org.springframework.data.repository.reactive.ReactiveCrudRepository import org.springframework.stereotype.Repository +import reactor.core.publisher.Mono @Repository -interface AccountRepository : ReactiveCrudRepository +interface AccountRepository : ReactiveCrudRepository { + fun findByUsername(username: String): Mono +} diff --git a/src/main/kotlin/ltd/hlaeja/service/AccountService.kt b/src/main/kotlin/ltd/hlaeja/service/AccountService.kt index 1dd646a..77f3714 100644 --- a/src/main/kotlin/ltd/hlaeja/service/AccountService.kt +++ b/src/main/kotlin/ltd/hlaeja/service/AccountService.kt @@ -21,4 +21,10 @@ class AccountService( ): Mono = accountRepository.findById(uuid) .doOnNext { log.debug { "Get account ${it.id}" } } .switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND))) + + fun getUserByUsername( + username: String, + ): Mono = accountRepository.findByUsername(username) + .doOnNext { log.debug { "Get account ${it.id} for username $username" } } + .switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND))) } diff --git a/src/main/kotlin/ltd/hlaeja/service/AuthenticationService.kt b/src/main/kotlin/ltd/hlaeja/service/AuthenticationService.kt new file mode 100644 index 0000000..2f6c58d --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/service/AuthenticationService.kt @@ -0,0 +1,37 @@ +package ltd.hlaeja.service + +import ltd.hlaeja.jwt.service.PrivateJwtService +import org.springframework.http.HttpStatus.LOCKED +import org.springframework.http.HttpStatus.UNAUTHORIZED +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import reactor.core.publisher.Mono + +@Service +class AuthenticationService( + private val accountService: AccountService, + private val passwordEncoder: PasswordEncoder, + private val privateJwtService: PrivateJwtService, +) { + fun authenticate( + username: String, + password: CharSequence, + ): Mono = accountService.getUserByUsername(username) + .flatMap { + if (!passwordEncoder.matches(password, it.password)) { + Mono.error(ResponseStatusException(UNAUTHORIZED, "Invalid password")) + } else if (!it.enabled) { + Mono.error(ResponseStatusException(LOCKED, "Account disabled")) + } else { + Mono.just(it) + } + } + .map { accountEntity -> + privateJwtService.sign( + "id" to accountEntity.id!!, + "username" to accountEntity.username, + "role" to accountEntity.roles, + ) + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d36450e..b2c402b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,6 +10,9 @@ spring: name: "%APP_BUILD_OS_NAME%" version: "%APP_BUILD_OS_VERSION%" +jwt: + private-key: cert/private_key.pem + --- ############################### ### Development environment ### diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..32c88c7 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,8 @@ +jwt: + private-key: cert/valid-private-key.pem + +spring: + r2dbc: + url: r2dbc:postgresql://placeholder + username: placeholder + password: placeholder diff --git a/src/test/resources/cert/valid-private-key.pem b/src/test/resources/cert/valid-private-key.pem new file mode 100644 index 0000000..3314ab6 --- /dev/null +++ b/src/test/resources/cert/valid-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC7VJTUt9Us8cKj +MzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvu +NMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZ +qgtzJ6GR3eqoYSW9b9UMvkBpZODSctWSNGj3P7jRFDO5VoTwCQAWbFnOjDfH5Ulg +p2PKSQnSJP3AJLQNFNe7br1XbrhV//eO+t51mIpGSDCUv3E0DDFcWDTH9cXDTTlR +ZVEiR2BwpZOOkE/Z0/BVnhZYL71oZV34bKfWjQIt6V/isSMahdsAASACp4ZTGtwi +VuNd9tybAgMBAAECggEBAKTmjaS6tkK8BlPXClTQ2vpz/N6uxDeS35mXpqasqskV +laAidgg/sWqpjXDbXr93otIMLlWsM+X0CqMDgSXKejLS2jx4GDjI1ZTXg++0AMJ8 +sJ74pWzVDOfmCEQ/7wXs3+cbnXhKriO8Z036q92Qc1+N87SI38nkGa0ABH9CN83H +mQqt4fB7UdHzuIRe/me2PGhIq5ZBzj6h3BpoPGzEP+x3l9YmK8t/1cN0pqI+dQwY +dgfGjackLu/2qH80MCF7IyQaseZUOJyKrCLtSD/Iixv/hzDEUPfOCjFDgTpzf3cw +ta8+oE4wHCo1iI1/4TlPkwmXx4qSXtmw4aQPz7IDQvECgYEA8KNThCO2gsC2I9PQ +DM/8Cw0O983WCDY+oi+7JPiNAJwv5DYBqEZB1QYdj06YD16XlC/HAZMsMku1na2T +N0driwenQQWzoev3g2S7gRDoS/FCJSI3jJ+kjgtaA7Qmzlgk1TxODN+G1H91HW7t +0l7VnL27IWyYo2qRRK3jzxqUiPUCgYEAx0oQs2reBQGMVZnApD1jeq7n4MvNLcPv +t8b/eU9iUv6Y4Mj0Suo/AU8lYZXm8ubbqAlwz2VSVunD2tOplHyMUrtCtObAfVDU +AhCndKaA9gApgfb3xw1IKbuQ1u4IF1FJl3VtumfQn//LiH1B3rXhcdyo3/vIttEk +48RakUKClU8CgYEAzV7W3COOlDDcQd935DdtKBFRAPRPAlspQUnzMi5eSHMD/ISL +DY5IiQHbIH83D4bvXq0X7qQoSBSNP7Dvv3HYuqMhf0DaegrlBuJllFVVq9qPVRnK +xt1Il2HgxOBvbhOT+9in1BzA+YJ99UzC85O0Qz06A+CmtHEy4aZ2kj5hHjECgYEA +mNS4+A8Fkss8Js1RieK2LniBxMgmYml3pfVLKGnzmng7H2+cwPLhPIzIuwytXywh +2bzbsYEfYx3EoEVgMEpPhoarQnYPukrJO4gwE2o5Te6T5mJSZGlQJQj9q4ZB2Dfz +et6INsK0oG8XVGXSpQvQh3RUYekCZQkBBFcpqWpbIEsCgYAnM3DQf3FJoSnXaMhr +VBIovic5l0xFkEHskAjFTevO86Fsz1C2aSeRKSqGFoOQ0tmJzBEs1R6KqnHInicD +TQrKhArgLXX4v3CddjfTRJkFWDbE/CkvKZNOrcf1nhaGCPspRJj2KUkj1Fhl9Cnc +dn/RsYEONbwQSjIfMPkvxF+8HQ== +-----END PRIVATE KEY----- \ No newline at end of file