diff --git a/.gitignore b/.gitignore index c2065bc..7493af0 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ out/ ### VS Code ### .vscode/ +.docker diff --git a/README.md b/README.md index 385ace8..c8f7e71 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,79 @@ $ java -version openjdk version "17.0.3.1" 2022-04-22 LTS OpenJDK Runtime Environment (build 17.0.3.1+2-LTS) OpenJDK 64-Bit Server VM (build 17.0.3.1+2-LTS, mixed mode, sharing) -``` \ No newline at end of file +``` + +## setup DB + +- run Postgres + +```shell +$ docker compose up -d +``` + +- exec DDL + +```sql +drop table if exists customer_role; +drop sequence if exists customer_role_id_seq; + +drop table if exists customers; +drop sequence if exists customer_id_seq; + +create sequence customer_id_seq; +create table customers +( + id int not null default nextval('customer_id_seq') primary key, + email varchar(50) not null unique, + password varchar(500) not null +); + +drop table if exists roles; +drop sequence if exists role_id_seq; + +create sequence role_id_seq; +create table roles +( + id int not null default nextval('role_id_seq') primary key, + name varchar(50) not null unique +); + +create sequence customer_role_id_seq; +create table customer_role +( + id int not null default nextval('customer_role_id_seq') primary key, + customer_id int not null, + role_id int not null, + constraint fk_customer_role_customer_id foreign key (customer_id) references customers (id), + constraint fk_customer_role_role_id foreign key (role_id) references roles (id) +); + +-- initial data for admin user +insert into roles (id, name) +values (default, 'ADMIN'), + (default, 'USER'); + +insert into customers (id, email, password) +values (default, 'admin@example.com', '$2a$10$ancDG4fEZY31a9OtuqSbs./SPUu7s00qam5sXinI5NrTLSGlCy/BK'); + +insert into customer_role (customer_id, role_id) +values ((select c.id from customers c where c.email = 'admin@example.com'), + (select r.id from roles r where r.name = 'ADMIN')), + ((select c.id from customers c where c.email = 'admin@example.com'), + (select r.id from roles r where r.name = 'USER')); + +select c.*, r.name +from customers c + left join customer_role cr on c.id = cr.customer_id + left join roles r on cr.role_id = r.id; +``` + +## generate code for jOOQ + +```shell +$ ./gradlew generateJooq +``` + +## test API + +[use Postman collections](./postman/spring-security-zenn-ariticle.postman_collection.json) diff --git a/build.gradle.kts b/build.gradle.kts index 0948a1b..91ff298 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id("org.springframework.boot") version "3.0.1" id("io.spring.dependency-management") version "1.1.0" + // https://github.com/etiennestuder/gradle-jooq-plugin#compatibility + id("nu.studer.jooq") version "8.1" + kotlin("jvm") version "1.7.22" kotlin("plugin.spring") version "1.7.22" } @@ -19,14 +22,68 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-jooq") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + runtimeOnly("org.postgresql:postgresql") + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") + + jooqGenerator("org.postgresql:postgresql") + // https://github.com/etiennestuder/gradle-jooq-plugin/issues/207 + jooqGenerator("jakarta.xml.bind:jakarta.xml.bind-api:3.0.1") } +jooq { + version.set(dependencyManagement.importedProperties["jooq.version"]) + edition.set(nu.studer.gradle.jooq.JooqEdition.OSS) + + configurations { + create("main") { + // ref https://github.com/etiennestuder/gradle-jooq-plugin#generating-the-jooq-sources + // ref https://www.greptips.com/posts/1350/#jooq-configurations + generateSchemaSourceOnCompilation.set(false) + jooqConfiguration.apply { + jdbc.apply { + driver = "org.postgresql.Driver" + // TODO: 環境変数から読み取るようにし、direnv 等で設定する + // url = System.getenv("POSTGRES_URL") + // user = System.getenv("POSTGRES_USER") + // password = System.getenv("POSTGRES_PASSWORD") + url = "jdbc:postgresql://localhost:15432/testdb" + user = "postgres" + password = "password" + } + generator.apply { + name = "org.jooq.codegen.KotlinGenerator" + database.apply { + name = "org.jooq.meta.postgres.PostgresDatabase" + inputSchema = "public" + // excludes = "flyway_schema_history" + } + generate.apply { + isDeprecated = false + isTables = true + // isRecords = true + // isImmutablePojos = true + // isFluentSetters = true + } + target.apply { + packageName = "com.example.zenn.jooq.codegen" + directory = "build/generated-src/jooq/" + } + strategy.name = "org.jooq.codegen.DefaultGeneratorStrategy" + } + } + } + } +} + + tasks.withType { kotlinOptions { freeCompilerArgs = listOf("-Xjsr305=strict") diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..a96b29a --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:13.9 + container_name: spring-security-demo-postgres + ports: + - 15432:5432 + volumes: + - ./.docker/postgres:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: testdb + POSTGRES_INITDB_ARGS: "--encoding=UTF-8" + restart: always diff --git a/postman/spring-security-zenn-ariticle.postman_collection.json b/postman/spring-security-zenn-ariticle.postman_collection.json index d18f8f1..c35ade2 100644 --- a/postman/spring-security-zenn-ariticle.postman_collection.json +++ b/postman/spring-security-zenn-ariticle.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "9ec8378e-2619-438b-852d-1cfe49564179", + "_postman_id": "67340b5f-9ca3-427a-918e-9d4109b3d2e6", "name": "spring-security-zenn-ariticle", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "_exporter_id": "15499051" @@ -28,19 +28,82 @@ "response": [] }, { - "name": "private", + "name": "register customer(mike)", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"mike@example.com\",\n \"password\": \"2wsxzaq1\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:{{port}}/register", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "register" + ] + } + }, + "response": [] + }, + { + "name": "private with authorization header(admin)", "request": { "auth": { "type": "basic", "basic": [ + { + "key": "username", + "value": "admin@example.com", + "type": "string" + }, { "key": "password", "value": "1qazxsw2", "type": "string" - }, + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "localhost:{{port}}/private", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "private" + ] + } + }, + "response": [] + }, + { + "name": "private with authorization header(mike)", + "request": { + "auth": { + "type": "basic", + "basic": [ { "key": "username", - "value": "user", + "value": "mike@example.com", + "type": "string" + }, + { + "key": "password", + "value": "2wsxzaq1", "type": "string" } ] @@ -61,7 +124,52 @@ "response": [] }, { - "name": "public with authrization header", + "name": "add new role", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@example.com", + "type": "string" + }, + { + "key": "password", + "value": "1qazxsw2", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"TEST\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:{{port}}/roles", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "roles" + ] + } + }, + "response": [] + }, + { + "name": "get roles", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, "request": { "auth": { "type": "basic", @@ -73,21 +181,119 @@ }, { "key": "username", - "value": "test", + "value": "admin@example.com", "type": "string" } ] }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, "url": { - "raw": "localhost:{{port}}/public", + "raw": "localhost:{{port}}/roles", "host": [ "localhost" ], "port": "{{port}}", "path": [ - "public" + "roles" + ] + } + }, + "response": [] + }, + { + "name": "get customers", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "1qazxsw2", + "type": "string" + }, + { + "key": "username", + "value": "admin@example.com", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:{{port}}/customers", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "customers" + ] + } + }, + "response": [] + }, + { + "name": "attach role", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@example.com", + "type": "string" + }, + { + "key": "password", + "value": "1qazxsw2", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "[\"TEST\"]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:{{port}}/customers/22/roles", + "host": [ + "localhost" + ], + "port": "{{port}}", + "path": [ + "customers", + "22", + "roles" ] } }, diff --git a/src/main/kotlin/com/example/zenn/AdminController.kt b/src/main/kotlin/com/example/zenn/AdminController.kt new file mode 100644 index 0000000..11cd973 --- /dev/null +++ b/src/main/kotlin/com/example/zenn/AdminController.kt @@ -0,0 +1,33 @@ +package com.example.zenn + +import com.example.zenn.domain.customer.Customer +import com.example.zenn.domain.customer.CustomerRepository +import com.example.zenn.domain.role.Role +import com.example.zenn.domain.role.RoleRepository +import org.springframework.web.bind.annotation.* +import java.util.* + +/** + * @author kiyota + */ +@RestController +class AdminController( + // TODO: usecase layer をもうけてエラーハンドリング + private val customerRepository: CustomerRepository, + private val roleRepository: RoleRepository, +) { + // @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/roles") + fun addRole(@RequestBody role: Role): Role = roleRepository.save(role) + + @GetMapping("/roles") + fun getRoles(): List = roleRepository.findAll() + + @GetMapping("/customers") + fun getCustomers(): List = customerRepository.findAll() + + @PutMapping("/customers/{customerId}/roles") + fun attachRoles(@PathVariable customerId: Int, @RequestBody roles: List): Customer { + return customerRepository.attachRoles(customerId, roles) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/zenn/CustomerController.kt b/src/main/kotlin/com/example/zenn/CustomerController.kt new file mode 100644 index 0000000..fc32cdc --- /dev/null +++ b/src/main/kotlin/com/example/zenn/CustomerController.kt @@ -0,0 +1,32 @@ +package com.example.zenn + +import com.example.zenn.domain.customer.Customer +import com.example.zenn.security.CustomerDetails +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +/** + * @author kiyota + */ +@RestController +class CustomerController( + private val customerDetails: CustomerDetails, +) { + @PostMapping("/register") + fun registerUser(@RequestBody customer: Customer): ResponseEntity<*> { + val response: ResponseEntity<*> = try { + val savedCustomer: Customer = customerDetails.register(customer); + ResponseEntity + .status(HttpStatus.CREATED) + .body("Given user details are successfully registered $savedCustomer") + } catch (ex: Exception) { + ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("An exception occurred due to " + ex.message) + } + return response + } +} diff --git a/src/main/kotlin/com/example/zenn/SampleApplication.kt b/src/main/kotlin/com/example/zenn/SampleApplication.kt index b4c072a..4a37a7e 100644 --- a/src/main/kotlin/com/example/zenn/SampleApplication.kt +++ b/src/main/kotlin/com/example/zenn/SampleApplication.kt @@ -3,11 +3,12 @@ package com.example.zenn import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication import org.springframework.context.ConfigurableApplicationContext +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity -import kotlin.reflect.typeOf @SpringBootApplication -@EnableWebSecurity(debug = true) +// @EnableWebSecurity(debug = true) +// @EnableMethodSecurity class SampleApplication fun main(args: Array) { diff --git a/src/main/kotlin/com/example/zenn/SecurityConfig.kt b/src/main/kotlin/com/example/zenn/SecurityConfig.kt deleted file mode 100644 index e07768b..0000000 --- a/src/main/kotlin/com/example/zenn/SecurityConfig.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.example.zenn - -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.security.core.userdetails.User -import org.springframework.security.core.userdetails.UserDetails -import org.springframework.security.provisioning.InMemoryUserDetailsManager -import org.springframework.security.provisioning.UserDetailsManager - -/** - * @author kiyota - */ -@Configuration -class SecurityConfig { - - @Bean - fun userDetailsManager(): UserDetailsManager { - val admin: UserDetails = User.builder() - .username("admin") - // encode with Spring Boot CLI - // https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html#authentication-password-storage-boot-cli - // $ spring encodepassword 1qazxsw2 - .password("{bcrypt}\$2a\$10\$1gHHMqYmv7spE.896lYtKuenhXSRGyZ0FK.JTzAOSD6qgRKtPl5wy") - .authorities("USER", "ADMIN") - .build() - val user: UserDetails = User.builder() - .username("user") - // $ spring encodepassword 2wsxzaq1 - .password("{bcrypt}\$2a\$10\$saAFPwyIghNePc0C4sKuUOBUIQBs6xnC8sUh2OvLW6fuU57oJ1tp6") - .authorities("USER") - .build() - return InMemoryUserDetailsManager(admin, user) - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/example/zenn/domain/customer/Customer.kt b/src/main/kotlin/com/example/zenn/domain/customer/Customer.kt new file mode 100644 index 0000000..bc09366 --- /dev/null +++ b/src/main/kotlin/com/example/zenn/domain/customer/Customer.kt @@ -0,0 +1,25 @@ +package com.example.zenn.domain.customer + +import com.example.zenn.domain.role.Role + +class Customer private constructor( + id: Int?, email: String, password: String, roles: Set? +) { + var id: Int? = id + val email = email + val password = password + val roles: Set? = roles + + companion object { + fun create(email: String, password: String, roles: Set?): Customer { + return Customer(null, email, password, roles) + } + fun reconstruct(id: Int, email: String, password: String, roles: Set): Customer { + return Customer(id, email, password, roles) + } + } + + override fun toString(): String { + return "Customer(id=$id, email='$email', password='$password', roles=$roles)" + } +} diff --git a/src/main/kotlin/com/example/zenn/domain/customer/CustomerRepository.kt b/src/main/kotlin/com/example/zenn/domain/customer/CustomerRepository.kt new file mode 100644 index 0000000..ca034e0 --- /dev/null +++ b/src/main/kotlin/com/example/zenn/domain/customer/CustomerRepository.kt @@ -0,0 +1,11 @@ +package com.example.zenn.domain.customer + +/** + * @author kiyota + */ +interface CustomerRepository { + fun findByEmail(email: String): Customer? + fun save(customer: Customer): Customer + fun findAll(): List + fun attachRoles(customerId: Int, roles: List): Customer +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/zenn/domain/role/Role.kt b/src/main/kotlin/com/example/zenn/domain/role/Role.kt new file mode 100644 index 0000000..fcdb74d --- /dev/null +++ b/src/main/kotlin/com/example/zenn/domain/role/Role.kt @@ -0,0 +1,21 @@ +package com.example.zenn.domain.role + +class Role private constructor( + id: Int?, name: String +){ + var id: Int? = id + val name = name + + companion object { + fun create(name: String): Role { + return Role(null, name) + } + fun reconstruct(id: Int, name: String): Role { + return Role(id, name) + } + } + + override fun toString(): String { + return "Role(id=$id, name='$name')" + } +} diff --git a/src/main/kotlin/com/example/zenn/domain/role/RoleRepository.kt b/src/main/kotlin/com/example/zenn/domain/role/RoleRepository.kt new file mode 100644 index 0000000..bfa849b --- /dev/null +++ b/src/main/kotlin/com/example/zenn/domain/role/RoleRepository.kt @@ -0,0 +1,10 @@ +package com.example.zenn.domain.role + +/** + * @author kiyota + */ +interface RoleRepository { + fun findByName(name: String): Role? + fun save(role: Role): Role + fun findAll(): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/zenn/infrastructure/repository/jooq/CustomerJooqRepository.kt b/src/main/kotlin/com/example/zenn/infrastructure/repository/jooq/CustomerJooqRepository.kt new file mode 100644 index 0000000..0f63d45 --- /dev/null +++ b/src/main/kotlin/com/example/zenn/infrastructure/repository/jooq/CustomerJooqRepository.kt @@ -0,0 +1,125 @@ +package com.example.zenn.infrastructure.repository.jooq + +import com.example.zenn.domain.customer.Customer +import com.example.zenn.domain.customer.CustomerRepository +import com.example.zenn.domain.role.Role +import com.example.zenn.jooq.codegen.tables.CustomerRole +import com.example.zenn.jooq.codegen.tables.Customers +import com.example.zenn.jooq.codegen.tables.Roles +import com.example.zenn.jooq.codegen.tables.records.CustomersRecord +import com.example.zenn.jooq.codegen.tables.references.CUSTOMERS +import com.example.zenn.jooq.codegen.tables.references.CUSTOMER_ROLE +import com.example.zenn.jooq.codegen.tables.references.ROLES +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.Record1 +import org.jooq.Result +import org.jooq.impl.DSL.exists +import org.jooq.impl.DSL.select +import org.jooq.impl.QOM.Exists +import org.springframework.stereotype.Repository + +@Repository +class CustomerJooqRepository(private val create: DSLContext) : CustomerRepository { + + val c: Customers = CUSTOMERS.`as`("c") + val cr: CustomerRole = CUSTOMER_ROLE.`as`("cr") + val r: Roles = ROLES.`as`("r") + + override fun findByEmail(email: String): Customer? { + + val records = create.select() + .from(c.leftOuterJoin(cr).on(cr.CUSTOMER_ID.eq(c.ID))) + .leftOuterJoin(r).on(r.ID.eq(cr.ROLE_ID)) + .where(c.EMAIL.eq(email)) + .fetch() + + if (records.isEmpty()) { + return null + } + + val roles = records.map { + Role.reconstruct( + id = it.getValue(r.ID)!!, + name = it.getValue(r.NAME)!! + ) + }.toSet() + + val first: Record = records.first() + + return Customer.reconstruct( + id = first.getValue(c.ID)!!, + email = first.getValue(c.EMAIL)!!, + password = first.getValue(c.PASSWORD)!!, + roles = roles + ); + } + + override fun save(customer: Customer): Customer { + val returningCustomer: CustomersRecord = create.insertInto(c, c.EMAIL, c.PASSWORD) + .values(customer.email, customer.password) + .returning() + .first() + + create.insertInto(cr, cr.CUSTOMER_ID, cr.ROLE_ID) + .values(returningCustomer.getValue(c.ID), customer.roles?.first()?.id) + .execute() + + return returningCustomer.map { + Customer.reconstruct( + it.getValue(c.ID)!!, + it.getValue(c.EMAIL)!!, + it.getValue(c.PASSWORD)!!, + setOfNotNull(customer.roles?.first()) + ) + } + } + + override fun findAll(): List { + val records = create.select(c.ID, c.EMAIL, c.PASSWORD, r.ID, r.NAME) + .from(c.leftOuterJoin(cr).on(cr.CUSTOMER_ID.eq(c.ID))) + .leftOuterJoin(r).on(r.ID.eq(cr.ROLE_ID)) + .fetch() + .intoGroups(c.EMAIL) + + val customers = mutableListOf() + + records.values.map { + val roles = it.map { + Role.reconstruct( + id = it.getValue(r.ID)!!, + name = it.getValue(r.NAME)!! + ) + }.toSet() + + val reconstruct: Customer = Customer.reconstruct( + id = it.first().getValue(c.ID)!!, + email = it.first().getValue(c.EMAIL)!!, + password = it.first().getValue(c.PASSWORD)!!, + roles = roles + ) + customers.add(reconstruct) + } + return customers + } + + override fun attachRoles(customerId: Int, roles: List): Customer { + val customer = create.select() + .from(c) + .where(c.ID.eq(customerId)) + .first() + + val existsRoles = create.select() + .from(r) + .where(r.NAME.`in`(roles)) + .fetch() + + existsRoles.map { + create.insertInto(cr, cr.CUSTOMER_ID, cr.ROLE_ID) + .values(customer.getValue(c.ID), it.getValue(r.ID)) + .execute() + } + + return findByEmail(customer.getValue(c.EMAIL)!!)!! + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/zenn/infrastructure/repository/jooq/RoleJooqRepository.kt b/src/main/kotlin/com/example/zenn/infrastructure/repository/jooq/RoleJooqRepository.kt new file mode 100644 index 0000000..a44e79b --- /dev/null +++ b/src/main/kotlin/com/example/zenn/infrastructure/repository/jooq/RoleJooqRepository.kt @@ -0,0 +1,63 @@ +package com.example.zenn.infrastructure.repository.jooq + +import com.example.zenn.domain.role.Role +import com.example.zenn.domain.role.RoleRepository +import com.example.zenn.jooq.codegen.tables.CustomerRole +import com.example.zenn.jooq.codegen.tables.Roles +import com.example.zenn.jooq.codegen.tables.references.CUSTOMER_ROLE +import com.example.zenn.jooq.codegen.tables.references.ROLES +import org.jooq.DSLContext +import org.jooq.Record +import org.springframework.stereotype.Repository + +@Repository +class RoleJooqRepository(private val create:DSLContext) : RoleRepository { + + val r: Roles = ROLES.`as`("r") + + override fun findByName(name: String): Role? { + + val records = create.select() + .from(r) + .where(r.NAME.eq(name)) + .fetch() + + if(records.isEmpty()){ + return null + } + + val first: Record = records.first() + + return Role.reconstruct( + id = first.getValue(r.ID)!!, + name = first.getValue(r.NAME)!! + ) + } + + override fun save(role: Role): Role { + val returningRole = create.insertInto(r, r.NAME) + .values(role.name) + .returning() + .first() + + return returningRole.map { + Role.reconstruct( + it.getValue(r.ID)!!, + it.getValue(r.NAME)!! + ) + } + } + + override fun findAll(): List { + val records = create.select() + .from(r) + .fetch() + + return records.map { + Role.reconstruct( + id = it.getValue(r.ID)!!, + name = it.getValue(r.NAME)!! + ) + }.toList() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/zenn/CustomEncoder.kt b/src/main/kotlin/com/example/zenn/security/CustomEncoder.kt similarity index 97% rename from src/main/kotlin/com/example/zenn/CustomEncoder.kt rename to src/main/kotlin/com/example/zenn/security/CustomEncoder.kt index a0647da..e754118 100644 --- a/src/main/kotlin/com/example/zenn/CustomEncoder.kt +++ b/src/main/kotlin/com/example/zenn/security/CustomEncoder.kt @@ -1,4 +1,4 @@ -package com.example.zenn +package com.example.zenn.security import org.springframework.security.crypto.bcrypt.BCrypt import org.springframework.security.crypto.password.PasswordEncoder diff --git a/src/main/kotlin/com/example/zenn/security/CustomerDetails.kt b/src/main/kotlin/com/example/zenn/security/CustomerDetails.kt new file mode 100644 index 0000000..2cf475e --- /dev/null +++ b/src/main/kotlin/com/example/zenn/security/CustomerDetails.kt @@ -0,0 +1,43 @@ +package com.example.zenn.security + +import com.example.zenn.domain.customer.Customer +import com.example.zenn.domain.customer.CustomerRepository +import com.example.zenn.domain.role.Role +import com.example.zenn.domain.role.RoleRepository +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service + +/** + * @author kiyota + */ +@Service +class CustomerDetails( + private val customerRepository: CustomerRepository, + private val roleRepository: RoleRepository, + private val passwordEncoder: PasswordEncoder, +) : UserDetailsService { + + override fun loadUserByUsername(email: String): UserDetails { + val customer = customerRepository.findByEmail(email) + ?: throw UsernameNotFoundException("User details not found for the user : $email") + + val authorities: MutableList = ArrayList() + customer.roles?.map { + authorities.add(SimpleGrantedAuthority("ROLE_${it.name}")) + } + return User(customer.email, customer.password, authorities) + } + + fun register(customer: Customer): Customer { + val hashedPassword = passwordEncoder.encode(customer.password) + val userRole: Role? = roleRepository.findByName("USER") + val newCustomer = Customer.create(customer.email, hashedPassword, setOfNotNull(userRole)) + return customerRepository.save(newCustomer) + } +} diff --git a/src/main/kotlin/com/example/zenn/security/SecurityConfig.kt b/src/main/kotlin/com/example/zenn/security/SecurityConfig.kt new file mode 100644 index 0000000..7d5420d --- /dev/null +++ b/src/main/kotlin/com/example/zenn/security/SecurityConfig.kt @@ -0,0 +1,100 @@ +package com.example.zenn.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager + + +/** + * @author kiyota + */ +@Configuration +class SecurityConfig { + + // spring-boot-autoconfigure-3.0.1.jar!/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports + // org/springframework/boot/autoconfigure/security/servlet/SpringBootWebSecurityConfiguration.java + @Bean + @Throws(Exception::class) + fun defaultSecurityFilterChain(http: HttpSecurity): SecurityFilterChain? { + http + .authorizeHttpRequests { + it.requestMatchers("/register", "/public").permitAll() + .requestMatchers("/private").hasAnyRole("ADMIN", "USER") + .requestMatchers("/roles", "/customers/**").hasRole("ADMIN") + // .anyRequest().authenticated() + } + .formLogin() + .and().httpBasic() + .and().csrf().disable() + return http.build() + } + + // exec following DDL in advance + // custom for Postgres + // https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/jdbc.html#servlet-authentication-jdbc-schema-user + // `org/springframework/security/core/userdetails/jdbc/users.ddl` + /* + drop table if exists authorities; + drop table if exists users; + + drop sequence if exists user_id_seq; + -- SELECT currval('user_id_seq'); + drop sequence if exists authority_id_seq; + + create sequence user_id_seq; + create sequence authority_id_seq; + + create table users + ( + id int not null default nextval('user_id_seq') primary key, + username varchar(50) not null unique, + password varchar(500) not null, + enabled boolean not null + ); + + create table authorities + ( + id int not null default nextval('authority_id_seq') primary key, + username varchar(50) not null, + authority varchar(50) not null, + constraint fk_authorities_users foreign key (username) references users (username) + ); + + create unique index ix_auth_username on authorities (username, authority); + */ + + /* + @Bean + fun users(dataSource: DataSource): UserDetailsManager { + // create sample user + /* + val user: UserDetails = User.builder() + .username("admin") + // encode with Spring Boot CLI + // https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html#authentication-password-storage-boot-cli + // $ spring encodepassword 1qazxsw2 + .password("$2a\$10\$1gHHMqYmv7spE.896lYtKuenhXSRGyZ0FK.JTzAOSD6qgRKtPl5wy") + .authorities("USER", "ADMIN") + .build() + val admin: UserDetails = User.builder() + .username("user") + // $ spring encodepassword 2wsxzaq1 + .password("$2a\$10\$saAFPwyIghNePc0C4sKuUOBUIQBs6xnC8sUh2OvLW6fuU57oJ1tp6") + .authorities("USER") + .build() + + val users = JdbcUserDetailsManager(dataSource) + users.createUser(user) + users.createUser(admin) + return users + */ + return JdbcUserDetailsManager(dataSource) + } + */ + @Bean + fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 4f6853e..cc43d87 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,13 +1,13 @@ server: port: 9080 spring: - security: - user: - # https://docs.spring.io/spring-boot/docs/3.0.1/reference/htmlsingle/#application-properties.security.spring.security.user.name - name: test - password: 1qazxsw2 + datasource: + url: jdbc:postgresql://localhost:15432/testdb + driverClassName: org.postgresql.Driver + username: postgres + password: password -## https://docs.spring.io/spring-boot/docs/3.0.1/reference/htmlsingle/#features.logging.log-levels +logging.level.org.jooq: trace #logging: # level: # org: diff --git a/src/test/kotlin/com/example/zenn/PasswordEncodingTest.kt b/src/test/kotlin/com/example/zenn/PasswordEncodingTest.kt index cb92fb4..1a6f6bb 100644 --- a/src/test/kotlin/com/example/zenn/PasswordEncodingTest.kt +++ b/src/test/kotlin/com/example/zenn/PasswordEncodingTest.kt @@ -1,5 +1,6 @@ package com.example.zenn +import com.example.zenn.security.CustomEncoder import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.DisplayName