Authentication

- add AuthenticationController
- add AuthenticationService
- add getUserByUsername to AccountService
- add findByUsername to AccountRepository
- add SecurityConfiguration
- set up authentication
This commit is contained in:
2024-12-30 17:07:25 +01:00
parent 6aee16c4a2
commit 0681e367e7
14 changed files with 223 additions and 2 deletions

View File

@@ -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

3
.gitignore vendored
View File

@@ -38,3 +38,6 @@ out/
### Kotlin ###
.kotlin
#### Hlæja ###
/cert/

View File

@@ -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.

View File

@@ -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")
}

44
http/authentication.http Normal file
View File

@@ -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"
}

4
sql/test_001-account.sql Normal file
View File

@@ -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');

View File

@@ -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()
}

View File

@@ -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<Authentication.Response> = authenticationService.authenticate(request.username, request.password)
.map { Authentication.Response(it) }
}

View File

@@ -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<AccountEntity, UUID>
interface AccountRepository : ReactiveCrudRepository<AccountEntity, UUID> {
fun findByUsername(username: String): Mono<AccountEntity>
}

View File

@@ -21,4 +21,10 @@ class AccountService(
): Mono<AccountEntity> = accountRepository.findById(uuid)
.doOnNext { log.debug { "Get account ${it.id}" } }
.switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND)))
fun getUserByUsername(
username: String,
): Mono<AccountEntity> = accountRepository.findByUsername(username)
.doOnNext { log.debug { "Get account ${it.id} for username $username" } }
.switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND)))
}

View File

@@ -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<String> = 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,
)
}
}

View File

@@ -10,6 +10,9 @@ spring:
name: "%APP_BUILD_OS_NAME%"
version: "%APP_BUILD_OS_VERSION%"
jwt:
private-key: cert/private_key.pem
---
###############################
### Development environment ###

View File

@@ -0,0 +1,8 @@
jwt:
private-key: cert/valid-private-key.pem
spring:
r2dbc:
url: r2dbc:postgresql://placeholder
username: placeholder
password: placeholder

View File

@@ -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-----