From 55db4c734bc2e278e21e765b9353a8d98bb51e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J=C3=BAlio=20Moreira?= Date: Sun, 18 Sep 2022 12:59:40 +0100 Subject: [PATCH 01/15] Add basic models --- .../up/fe/ni/website/backend/model/Account.kt | 29 +++++++++++++++++++ .../ni/website/backend/model/CustomWebsite.kt | 15 ++++++++++ .../pt/up/fe/ni/website/backend/model/Role.kt | 16 ++++++++++ 3 files changed, 60 insertions(+) create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt new file mode 100644 index 00000000..a93a56f4 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -0,0 +1,29 @@ +package pt.up.fe.ni.website.backend.model + +import java.net.URL +import java.util.Date +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.Id +import javax.persistence.ManyToOne +import javax.persistence.OneToMany +import javax.persistence.OneToOne + +@Entity +class Account ( + @Column(nullable = false) + val name: String, + val bio: String?, + val birthDate: Date?, + val photo: String?, + val linkedin: String?, + @OneToMany + val websites: List, + + @ManyToOne + val role: Role, + + @Id @GeneratedValue + val id: Long? = null, +) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt new file mode 100644 index 00000000..9fa4f7b0 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt @@ -0,0 +1,15 @@ +package pt.up.fe.ni.website.backend.model + +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.Id + +@Entity +class CustomWebsite( + val url: String, + val icon: String?, + @Id + @GeneratedValue + val id: Long? = null +) \ No newline at end of file diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt new file mode 100644 index 00000000..327a48ee --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt @@ -0,0 +1,16 @@ +package pt.up.fe.ni.website.backend.model + +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.Id + +@Entity +class Role( + val name: String, + val permissions: Long, // 64 permissions + val boardMember: Boolean, + val boardPosition: Short?, + + @Id @GeneratedValue + val id: Long? = null, +) From a9410dd75cffaffeb2e609df6bacb3f91a224400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J=C3=BAlio=20Moreira?= Date: Sat, 24 Sep 2022 16:26:25 +0100 Subject: [PATCH 02/15] Add permissions and permissions tests --- .../up/fe/ni/website/backend/model/Account.kt | 6 +- .../ni/website/backend/model/CustomWebsite.kt | 3 +- .../fe/ni/website/backend/model/Permission.kt | 98 +++++++++++++++ .../up/fe/ni/website/backend/util/BitSet.kt | 29 +++++ .../website/backend/model/PermissionTest.kt | 116 ++++++++++++++++++ 5 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/Permission.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/util/BitSet.kt create mode 100644 src/test/kotlin/pt/up/fe/ni/website/backend/model/PermissionTest.kt diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt index a93a56f4..89695d38 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -1,6 +1,5 @@ package pt.up.fe.ni.website.backend.model -import java.net.URL import java.util.Date import javax.persistence.Column import javax.persistence.Entity @@ -8,10 +7,9 @@ import javax.persistence.GeneratedValue import javax.persistence.Id import javax.persistence.ManyToOne import javax.persistence.OneToMany -import javax.persistence.OneToOne @Entity -class Account ( +class Account( @Column(nullable = false) val name: String, val bio: String?, @@ -21,6 +19,8 @@ class Account ( @OneToMany val websites: List, + val permissions: Long, + @ManyToOne val role: Role, diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt index 9fa4f7b0..636794d8 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt @@ -1,6 +1,5 @@ package pt.up.fe.ni.website.backend.model -import javax.persistence.Column import javax.persistence.Entity import javax.persistence.GeneratedValue import javax.persistence.Id @@ -12,4 +11,4 @@ class CustomWebsite( @Id @GeneratedValue val id: Long? = null -) \ No newline at end of file +) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Permission.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Permission.kt new file mode 100644 index 00000000..5c1d8cdd --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Permission.kt @@ -0,0 +1,98 @@ +package pt.up.fe.ni.website.backend.model + +import pt.up.fe.ni.website.backend.util.bitSet +import pt.up.fe.ni.website.backend.util.enable +import java.util.BitSet + +var NUM_PERMISSIONS: Int = 3 + +class Permission( + var name: String, + private val flag: BitSet +) { + + constructor(bitSet: BitSet) : this("", bitSet) { + name = permissionName(bitSet) + } + + constructor(value: Long) : this(value.bitSet()) + + fun add(permission: Permission): Permission { + val newBitSet = BitSet(NUM_PERMISSIONS) + newBitSet.or(flag) + newBitSet.or(permission.flag) + + return Permission(newBitSet) + } + + fun remove(permission: Permission): Permission { + val newBitSet = BitSet(NUM_PERMISSIONS) + newBitSet.or(permission.flag) + newBitSet.flip(0, NUM_PERMISSIONS) + newBitSet.and(flag) + + return Permission(newBitSet) + } + + fun permissions(): List { + val permissions: ArrayList = ArrayList() + var bit: Int = 0 + + while (bit < NUM_PERMISSIONS) { + bit = flag.nextSetBit(bit) + + if (bit < 0) { + break + } + + val permission: Permission = BasePermissions.fromBit(bit) + permissions.add(permission) + + bit ++ + } + + return permissions + } + + private fun permissionName(bitSet: BitSet): String { + return when (bitSet.cardinality()) { + NUM_PERMISSIONS -> "all" + 0 -> "none" + 1 -> { + return when (bitSet.nextSetBit(0)) { + 0 -> "users" + 1 -> "projects" + 2 -> "events" + else -> "" + } + } + else -> "custom" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Permission + + if (name != other.name) return false + if (flag != other.flag) return false + + return true + } +} + +enum class BasePermissions(val value: Permission) { + USERS(Permission(BitSet(NUM_PERMISSIONS).enable(0))), + PROJECTS(Permission(BitSet(NUM_PERMISSIONS).enable(1))), + EVENTS(Permission(BitSet(NUM_PERMISSIONS).enable(2))), + ALL(Permission(BitSet(NUM_PERMISSIONS).enable(0, NUM_PERMISSIONS))), + NONE(Permission(BitSet(NUM_PERMISSIONS))); + + companion object { + fun fromBit(i: Int): Permission { + return BasePermissions.values()[i].value + } + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/util/BitSet.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/util/BitSet.kt new file mode 100644 index 00000000..f04f5bcb --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/util/BitSet.kt @@ -0,0 +1,29 @@ +package pt.up.fe.ni.website.backend.util + +import java.util.BitSet + +fun BitSet.enable(bit: Int): BitSet { + this.set(bit) + return this +} + +fun BitSet.enable(firstBit: Int, lastBit: Int): BitSet { + this.set(firstBit, lastBit) + return this +} + +fun Long.bitSet(): BitSet { + val bits = BitSet() + var index = 0 + var value: Long = this + + while (value != 0L) { + if ((value % 2L) != 0L) { + bits.set(index) + } + ++index + value = value ushr 1 + } + + return bits +} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/model/PermissionTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/model/PermissionTest.kt new file mode 100644 index 00000000..7384bd09 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/model/PermissionTest.kt @@ -0,0 +1,116 @@ +package pt.up.fe.ni.website.backend.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import pt.up.fe.ni.website.backend.util.enable +import java.util.BitSet + +internal class PermissionTest { + @Test + fun joinCustom() { + val perm1 = Permission(1) + val perm2 = Permission(2) + + val perm = perm1.add(perm2) + val list = perm.permissions() + + assertEquals(2, list.size) + assertEquals(BasePermissions.USERS.value, list[0]) // Bit 1 + assertEquals(BasePermissions.PROJECTS.value, list[1]) // Bit 2 + + assertEquals("custom", perm.name) + } + + @Test + fun joinSame() { + val perm1 = Permission(1) + val perm2 = Permission(1) + + val perm = perm1.add(perm2) + assertEquals("users", perm1) + } + + @Test + fun joinAll() { + val perm1 = Permission(1) + val perm2 = Permission(2) + val perm3 = Permission(4) + + val allPerms = perm1.add(perm2).add(perm3) + assertEquals("all", allPerms.name) + } + + @Test + fun remove() { + val basePerm = Permission(3) + val removePerm = Permission(2) + + val perm = basePerm.remove(removePerm) + + val list = perm.permissions() + + assertEquals(1, list.size) + assertEquals(BasePermissions.USERS.value, list[0]) // Bit 1 + + assertEquals("users", perm.name) + } + + @Test + fun removeCustom() { + val basePerm = BasePermissions.ALL.value + val removePerm = Permission(1) + + val perm = basePerm.remove(removePerm) + + val list = perm.permissions() + + assertEquals(2, list.size) + assertEquals(BasePermissions.PROJECTS.value, list[0]) // Bit 1 + assertEquals(BasePermissions.EVENTS.value, list[1]) // Bit 2 + + assertEquals("custom", perm.name) + } + + @Test + fun permissions() { + val bitSet = BitSet(3) + bitSet.enable(1) + bitSet.enable(2) + + val permission = Permission("custom", bitSet) + val list = permission.permissions() + + assertEquals(2, list.size) + assertEquals(BasePermissions.PROJECTS.value, list[0]) // Bit 1 + assertEquals(BasePermissions.EVENTS.value, list[1]) // Bit 2 + } + + @Test + fun permissionsFromLong() { + val bitSet = BitSet(3) + bitSet.enable(1) + bitSet.enable(2) + + val permission = Permission(6) // 011 + val list = permission.permissions() + + assertEquals(2, list.size) + assertEquals(BasePermissions.PROJECTS.value, list[0]) // Bit 1 + assertEquals(BasePermissions.EVENTS.value, list[1]) // Bit 2 + } + + @Test + fun permissionsName() { + assertEquals("none", BasePermissions.NONE.value.name) + assertEquals("all", BasePermissions.ALL.value.name) + assertEquals("projects", BasePermissions.PROJECTS.value.name) + assertEquals("events", BasePermissions.EVENTS.value.name) + assertEquals("users", BasePermissions.USERS.value.name) + + assertEquals("none", Permission(0).name) + assertEquals("all", Permission(7).name) + assertEquals("users", Permission(1).name) + assertEquals("projects", Permission(2).name) + assertEquals("events", Permission(4).name) + } +} From c104c755489667fc47309412979a7bdd36dd0b57 Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Sun, 9 Oct 2022 17:51:42 +0100 Subject: [PATCH 03/15] Add base code for jwt auth --- build.gradle.kts | 2 + .../ni/website/backend/BackendApplication.kt | 3 ++ .../ni/website/backend/config/AuthConfig.kt | 41 +++++++++++++++++++ .../backend/config/RSAKeyProperties.kt | 13 ++++++ .../up/fe/ni/website/backend/model/Account.kt | 2 +- src/main/resources/application.properties | 4 ++ src/main/resources/certs/private.pem | 28 +++++++++++++ src/main/resources/certs/public.pem | 9 ++++ 8 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/config/RSAKeyProperties.kt create mode 100644 src/main/resources/certs/private.pem create mode 100644 src/main/resources/certs/public.pem diff --git a/build.gradle.kts b/build.gradle.kts index 06d3d482..4f26849b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,8 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt index 70bf485c..9dc4d1f5 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt @@ -1,9 +1,12 @@ package pt.up.fe.ni.website.backend import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication +import pt.up.fe.ni.website.backend.config.RSAKeyProperties @SpringBootApplication +@EnableConfigurationProperties(RSAKeyProperties::class) class BackendApplication fun main(args: Array) { diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt new file mode 100644 index 00000000..148e3cf5 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt @@ -0,0 +1,41 @@ +package pt.up.fe.ni.website.backend.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.Customizer +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.core.userdetails.User +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain + +@Configuration +@EnableWebSecurity +class AuthConfig(val rsaKeys: RSAKeyProperties) { + @Bean + fun user(): InMemoryUserDetailsManager { + return InMemoryUserDetailsManager( + User.withUsername("admin").password("{noop}admin123").authorities("read").build() + ) + } + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + return http + .csrf { csrf -> csrf.disable() } + .authorizeRequests { auth -> auth.anyRequest().permitAll() } + .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) + .sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .httpBasic(Customizer.withDefaults()) + .build() + } + + @Bean + fun jwtDecoder(): JwtDecoder { + return NimbusJwtDecoder.withPublicKey(rsaKeys::publicKey.get()).build() + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/RSAKeyProperties.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/RSAKeyProperties.kt new file mode 100644 index 00000000..1be8545c --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/RSAKeyProperties.kt @@ -0,0 +1,13 @@ +package pt.up.fe.ni.website.backend.config + +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding + +@ConstructorBinding +@ConfigurationProperties(prefix = "rsa") +data class RSAKeyProperties( + val publicKey: RSAPublicKey, + val privateKey: RSAPrivateKey +) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt index 89695d38..d16c9788 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -25,5 +25,5 @@ class Account( val role: Role, @Id @GeneratedValue - val id: Long? = null, + val id: Long? = null ) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d4c23f13..5cd80f32 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -13,3 +13,7 @@ spring.h2.console.path=/h2 # API Settings server.error.whitelabel.enabled=false + +# RSA Config +rsa.private-key=classpath:certs/private.pem +rsa.public-key=classpath:certs/public.pem \ No newline at end of file diff --git a/src/main/resources/certs/private.pem b/src/main/resources/certs/private.pem new file mode 100644 index 00000000..8d14dbb6 --- /dev/null +++ b/src/main/resources/certs/private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDwTsSRoUUedZ9i +Adrt54imDAWwNQjOApH4MHEc8Z0lNW6c3RJZCjKAOUI1uXbIuQMIPjxn8aDJumLV +Db7KmaG7+H55OsXCzJdozH3BujQ1qTKjb5WDiuSUKcgGXUDnADPpWRr3wraBZolA +Jn9MG+DFqiLlZuJtN/NJRIYhGiglsxrh3lf5KGBcfk2IXccOfLUoNCEW+846YI7t +zgkbQER6Sy+iXiAgpc0vKCTWBrCdwDSuUfcWylT8tOFTrQkLJ3cLxmLY5iU3AsbY +kYw0gQNMRiKTp8hxU8nzbsocVFS45NWYxjpkh75r0zECYSGqSJMUrYvCqJlv5M02 +iunQFiKXAgMBAAECggEBAN6Lw+UuWgmEWq90EmEifF1yYs41v0qx/KbBje+FHsg3 +vJGO9o/5Lp2q6VNBx+zJ0jIPGPgWQJaxgxfWG+wa7TpcPhxdPopR2KKYRppjrDhJ +0nijPO7OcTN5oiGquRF1EZ44BA6Rh109LTx4qok8hCPqlVinuGf3WdpvmFwNkkKd +2Y3dhVJywcfA3hEQ2kxxTkLPRcbx58NcifnB58T0qs9sylxpxvVXa19kMeQXgTv1 +1YDX1HWt5ccVx24Sshh03l/sr9ZXL4RUbbHO2HvyLJ91KDO65wdAySxuE6Hytdnp +K0Dri0s/V91eF3RCa+GAGAEFlgdFjEKYf0WMj0qRy4kCgYEA+Wb3Vh4Qkb6u/UoA +4OtWSTYs4ajU1YrmQlpYH79ZSMhf3NSk4Jb8HWMI7946irPNcjFWkPOMIt5VqdnS +cGiTR3w/mZjrOGR+siqMjLbs0/mxksc6w303Rrp/gArDFGSUencRQLDouefqMz+e +o/rUhZhcA3ELKOKSrzx8Mqm6KyUCgYEA9qo16Eyl9rOheTm5NiNNuLdAzsJusupM +y2IZMuUy639oIRcKGKXTEfmiqY4aN3zzs9yZ/hqg3lmpS1wq3PPay4MC98Cw2Ik5 +nwge/wgbLqDPwgJthbXEQ0o/k+ZF3V6SZU4TjstcfvJaZD4pG94C24+9Wfw4Xe3e +4zMQU83OqAsCgYAWGEclO/if0NLT4bB+PJsiVUhYnYpteKa5jiNsfJk+V3IWsEgD +FZ00RUfPaFKrYw56ZWCT6t+pXyUbrQ51ou4ZUSqZQvDjyBNpWVemR7ZneSGALWJJ +W1iATZlqEIoDzn1Q9Cd1IbcccS1QaPx27ovRYhQUwfkJIDl6iNM/8cVqeQKBgQDu +bRC2foBdurx2ZSl1/yH9ToVCVgaSwo+AeE5LN+jEYd7RPWfw8zjWwypMIqOMxyb5 +0F65lBuzUY+m3GxCLyRqWzTfLk7Cv8IGyt7LPZaot6Cas6YR/OS89mQGHiuiEuwH +KDUXbdL2kmR1SPCLk0nH2WT6OiZyBJ/RlWZO2zzKiQKBgQCgOpbsay9P7v1GPR/1 +0DZdS/TCUBqhzJtB/SDT+OCBp6VBPGlW4NZpOtBN3mcWycmFmbEAEN9P9MYRWTNV +HXGpUs8+TkSNPyJZVXScdaN2HDSyjtSG8kRhe6P58/zZRDwPRguQ9ts5K9k3ZY+w +2sAhxDTOxSQigZZskvPky8jiSw== +-----END PRIVATE KEY----- diff --git a/src/main/resources/certs/public.pem b/src/main/resources/certs/public.pem new file mode 100644 index 00000000..85291ec0 --- /dev/null +++ b/src/main/resources/certs/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8E7EkaFFHnWfYgHa7eeI +pgwFsDUIzgKR+DBxHPGdJTVunN0SWQoygDlCNbl2yLkDCD48Z/Ggybpi1Q2+ypmh +u/h+eTrFwsyXaMx9wbo0Nakyo2+Vg4rklCnIBl1A5wAz6Vka98K2gWaJQCZ/TBvg +xaoi5WbibTfzSUSGIRooJbMa4d5X+ShgXH5NiF3HDny1KDQhFvvOOmCO7c4JG0BE +eksvol4gIKXNLygk1gawncA0rlH3FspU/LThU60JCyd3C8Zi2OYlNwLG2JGMNIED +TEYik6fIcVPJ827KHFRUuOTVmMY6ZIe+a9MxAmEhqkiTFK2LwqiZb+TNNorp0BYi +lwIDAQAB +-----END PUBLIC KEY----- From cd36af33c03c8ef16e61c1426f26bc2af790029e Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Sun, 9 Oct 2022 18:44:28 +0100 Subject: [PATCH 04/15] Add get token endpoint --- .../ni/website/backend/config/AuthConfig.kt | 11 +++++++ .../backend/controller/AuthController.kt | 20 +++++++++++++ .../backend/controller/ErrorController.kt | 5 +++- .../ni/website/backend/service/AuthService.kt | 29 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt index 148e3cf5..9bface20 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt @@ -1,5 +1,8 @@ package pt.up.fe.ni.website.backend.config +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.source.ImmutableJWKSet import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.security.config.Customizer @@ -9,7 +12,9 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.ser import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.core.userdetails.User import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.NimbusJwtDecoder +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.security.web.SecurityFilterChain @@ -38,4 +43,10 @@ class AuthConfig(val rsaKeys: RSAKeyProperties) { fun jwtDecoder(): JwtDecoder { return NimbusJwtDecoder.withPublicKey(rsaKeys::publicKey.get()).build() } + + @Bean + fun jwtEncoder(): JwtEncoder { + val jwt = RSAKey.Builder(rsaKeys::publicKey.get()).privateKey(rsaKeys::privateKey.get()).build() + return NimbusJwtEncoder(ImmutableJWKSet(JWKSet(jwt))) + } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt new file mode 100644 index 00000000..a83148f8 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -0,0 +1,20 @@ +package pt.up.fe.ni.website.backend.controller + +import org.springframework.security.core.Authentication +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RestController +import pt.up.fe.ni.website.backend.service.AuthService + +@RestController +class AuthController(val authService: AuthService) { + + @PostMapping("/token") + fun getToken(authentication: Authentication) = authService.generateToken(authentication) + + // Just a locked endpoint to test the auth... Delete this when authorization is properly handled (permissions) + @GetMapping("/secret") + fun authCheck(): Map { + return mapOf("authenticated" to "true") + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt index 5ae2f0b4..d0ccc4ae 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt @@ -1,7 +1,9 @@ package pt.up.fe.ni.website.backend.controller import org.springframework.boot.web.servlet.error.ErrorController +import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController @RestController @@ -9,5 +11,6 @@ class ErrorController : ErrorController { val errorKey: String = "error" @RequestMapping("/**") - fun endpointNotFound() = mapOf(errorKey to "invalid endpoint") + @ResponseStatus(HttpStatus.BAD_REQUEST) + fun invalidRequest() = mapOf(errorKey to "invalid request") } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt new file mode 100644 index 00000000..7d2dfa66 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -0,0 +1,29 @@ +package pt.up.fe.ni.website.backend.service + +import java.time.Instant +import java.util.stream.Collectors +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.oauth2.jwt.JwtClaimsSet +import org.springframework.security.oauth2.jwt.JwtEncoder +import org.springframework.security.oauth2.jwt.JwtEncoderParameters +import org.springframework.stereotype.Service + +@Service +class AuthService(val encoder: JwtEncoder) { + fun generateToken(authentication: Authentication): String { + val scope = authentication + .authorities + .stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(" ")) + val claims = JwtClaimsSet + .builder() + .issuer("self") + .issuedAt(Instant.now()) + .subject(authentication.name) + .claim("scope", scope) + .build() + return encoder.encode(JwtEncoderParameters.from(claims)).tokenValue + } +} From 4128f2cc46e9753250048e706556569e39548216 Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Thu, 17 Nov 2022 11:59:57 +0000 Subject: [PATCH 05/15] Simplify Accounts --- .../up/fe/ni/website/backend/model/Account.kt | 11 -- .../ni/website/backend/model/CustomWebsite.kt | 14 --- .../fe/ni/website/backend/model/Permission.kt | 98 --------------- .../pt/up/fe/ni/website/backend/model/Role.kt | 16 --- .../up/fe/ni/website/backend/util/BitSet.kt | 29 ----- .../website/backend/model/PermissionTest.kt | 116 ------------------ 6 files changed, 284 deletions(-) delete mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt delete mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/Permission.kt delete mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt delete mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/util/BitSet.kt delete mode 100644 src/test/kotlin/pt/up/fe/ni/website/backend/model/PermissionTest.kt diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt index d16c9788..c7f15658 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -12,17 +12,6 @@ import javax.persistence.OneToMany class Account( @Column(nullable = false) val name: String, - val bio: String?, - val birthDate: Date?, - val photo: String?, - val linkedin: String?, - @OneToMany - val websites: List, - - val permissions: Long, - - @ManyToOne - val role: Role, @Id @GeneratedValue val id: Long? = null diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt deleted file mode 100644 index 636794d8..00000000 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt +++ /dev/null @@ -1,14 +0,0 @@ -package pt.up.fe.ni.website.backend.model - -import javax.persistence.Entity -import javax.persistence.GeneratedValue -import javax.persistence.Id - -@Entity -class CustomWebsite( - val url: String, - val icon: String?, - @Id - @GeneratedValue - val id: Long? = null -) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Permission.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Permission.kt deleted file mode 100644 index 5c1d8cdd..00000000 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Permission.kt +++ /dev/null @@ -1,98 +0,0 @@ -package pt.up.fe.ni.website.backend.model - -import pt.up.fe.ni.website.backend.util.bitSet -import pt.up.fe.ni.website.backend.util.enable -import java.util.BitSet - -var NUM_PERMISSIONS: Int = 3 - -class Permission( - var name: String, - private val flag: BitSet -) { - - constructor(bitSet: BitSet) : this("", bitSet) { - name = permissionName(bitSet) - } - - constructor(value: Long) : this(value.bitSet()) - - fun add(permission: Permission): Permission { - val newBitSet = BitSet(NUM_PERMISSIONS) - newBitSet.or(flag) - newBitSet.or(permission.flag) - - return Permission(newBitSet) - } - - fun remove(permission: Permission): Permission { - val newBitSet = BitSet(NUM_PERMISSIONS) - newBitSet.or(permission.flag) - newBitSet.flip(0, NUM_PERMISSIONS) - newBitSet.and(flag) - - return Permission(newBitSet) - } - - fun permissions(): List { - val permissions: ArrayList = ArrayList() - var bit: Int = 0 - - while (bit < NUM_PERMISSIONS) { - bit = flag.nextSetBit(bit) - - if (bit < 0) { - break - } - - val permission: Permission = BasePermissions.fromBit(bit) - permissions.add(permission) - - bit ++ - } - - return permissions - } - - private fun permissionName(bitSet: BitSet): String { - return when (bitSet.cardinality()) { - NUM_PERMISSIONS -> "all" - 0 -> "none" - 1 -> { - return when (bitSet.nextSetBit(0)) { - 0 -> "users" - 1 -> "projects" - 2 -> "events" - else -> "" - } - } - else -> "custom" - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Permission - - if (name != other.name) return false - if (flag != other.flag) return false - - return true - } -} - -enum class BasePermissions(val value: Permission) { - USERS(Permission(BitSet(NUM_PERMISSIONS).enable(0))), - PROJECTS(Permission(BitSet(NUM_PERMISSIONS).enable(1))), - EVENTS(Permission(BitSet(NUM_PERMISSIONS).enable(2))), - ALL(Permission(BitSet(NUM_PERMISSIONS).enable(0, NUM_PERMISSIONS))), - NONE(Permission(BitSet(NUM_PERMISSIONS))); - - companion object { - fun fromBit(i: Int): Permission { - return BasePermissions.values()[i].value - } - } -} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt deleted file mode 100644 index 327a48ee..00000000 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt +++ /dev/null @@ -1,16 +0,0 @@ -package pt.up.fe.ni.website.backend.model - -import javax.persistence.Entity -import javax.persistence.GeneratedValue -import javax.persistence.Id - -@Entity -class Role( - val name: String, - val permissions: Long, // 64 permissions - val boardMember: Boolean, - val boardPosition: Short?, - - @Id @GeneratedValue - val id: Long? = null, -) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/util/BitSet.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/util/BitSet.kt deleted file mode 100644 index f04f5bcb..00000000 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/util/BitSet.kt +++ /dev/null @@ -1,29 +0,0 @@ -package pt.up.fe.ni.website.backend.util - -import java.util.BitSet - -fun BitSet.enable(bit: Int): BitSet { - this.set(bit) - return this -} - -fun BitSet.enable(firstBit: Int, lastBit: Int): BitSet { - this.set(firstBit, lastBit) - return this -} - -fun Long.bitSet(): BitSet { - val bits = BitSet() - var index = 0 - var value: Long = this - - while (value != 0L) { - if ((value % 2L) != 0L) { - bits.set(index) - } - ++index - value = value ushr 1 - } - - return bits -} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/model/PermissionTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/model/PermissionTest.kt deleted file mode 100644 index 7384bd09..00000000 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/model/PermissionTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package pt.up.fe.ni.website.backend.model - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import pt.up.fe.ni.website.backend.util.enable -import java.util.BitSet - -internal class PermissionTest { - @Test - fun joinCustom() { - val perm1 = Permission(1) - val perm2 = Permission(2) - - val perm = perm1.add(perm2) - val list = perm.permissions() - - assertEquals(2, list.size) - assertEquals(BasePermissions.USERS.value, list[0]) // Bit 1 - assertEquals(BasePermissions.PROJECTS.value, list[1]) // Bit 2 - - assertEquals("custom", perm.name) - } - - @Test - fun joinSame() { - val perm1 = Permission(1) - val perm2 = Permission(1) - - val perm = perm1.add(perm2) - assertEquals("users", perm1) - } - - @Test - fun joinAll() { - val perm1 = Permission(1) - val perm2 = Permission(2) - val perm3 = Permission(4) - - val allPerms = perm1.add(perm2).add(perm3) - assertEquals("all", allPerms.name) - } - - @Test - fun remove() { - val basePerm = Permission(3) - val removePerm = Permission(2) - - val perm = basePerm.remove(removePerm) - - val list = perm.permissions() - - assertEquals(1, list.size) - assertEquals(BasePermissions.USERS.value, list[0]) // Bit 1 - - assertEquals("users", perm.name) - } - - @Test - fun removeCustom() { - val basePerm = BasePermissions.ALL.value - val removePerm = Permission(1) - - val perm = basePerm.remove(removePerm) - - val list = perm.permissions() - - assertEquals(2, list.size) - assertEquals(BasePermissions.PROJECTS.value, list[0]) // Bit 1 - assertEquals(BasePermissions.EVENTS.value, list[1]) // Bit 2 - - assertEquals("custom", perm.name) - } - - @Test - fun permissions() { - val bitSet = BitSet(3) - bitSet.enable(1) - bitSet.enable(2) - - val permission = Permission("custom", bitSet) - val list = permission.permissions() - - assertEquals(2, list.size) - assertEquals(BasePermissions.PROJECTS.value, list[0]) // Bit 1 - assertEquals(BasePermissions.EVENTS.value, list[1]) // Bit 2 - } - - @Test - fun permissionsFromLong() { - val bitSet = BitSet(3) - bitSet.enable(1) - bitSet.enable(2) - - val permission = Permission(6) // 011 - val list = permission.permissions() - - assertEquals(2, list.size) - assertEquals(BasePermissions.PROJECTS.value, list[0]) // Bit 1 - assertEquals(BasePermissions.EVENTS.value, list[1]) // Bit 2 - } - - @Test - fun permissionsName() { - assertEquals("none", BasePermissions.NONE.value.name) - assertEquals("all", BasePermissions.ALL.value.name) - assertEquals("projects", BasePermissions.PROJECTS.value.name) - assertEquals("events", BasePermissions.EVENTS.value.name) - assertEquals("users", BasePermissions.USERS.value.name) - - assertEquals("none", Permission(0).name) - assertEquals("all", Permission(7).name) - assertEquals("users", Permission(1).name) - assertEquals("projects", Permission(2).name) - assertEquals("events", Permission(4).name) - } -} From 88e9b7684b42193a3ca78405659f72f5a9a37036 Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Wed, 23 Nov 2022 15:27:54 +0000 Subject: [PATCH 06/15] Add support for guard rules --- build.gradle.kts | 2 +- .../ni/website/backend/config/AuthConfig.kt | 22 +++-------- .../backend/controller/AuthController.kt | 16 +++++--- .../backend/controller/ErrorController.kt | 7 ++++ .../ni/website/backend/service/AuthService.kt | 39 ++++++++++++------- 5 files changed, 51 insertions(+), 35 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 67b615e7..76e0aaa8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,7 +25,7 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") - implementation("org.springframework.boot:spring-boot-starter-validation:2.7.3") + implementation("org.springframework.boot:spring-boot-starter-validation:2.7.5") developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt index 9bface20..d0ede085 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt @@ -6,37 +6,27 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.security.config.Customizer +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.EnableWebSecurity import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer import org.springframework.security.config.http.SessionCreationPolicy -import org.springframework.security.core.userdetails.User import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.NimbusJwtDecoder import org.springframework.security.oauth2.jwt.NimbusJwtEncoder -import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.security.web.SecurityFilterChain @Configuration @EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) class AuthConfig(val rsaKeys: RSAKeyProperties) { - @Bean - fun user(): InMemoryUserDetailsManager { - return InMemoryUserDetailsManager( - User.withUsername("admin").password("{noop}admin123").authorities("read").build() - ) - } - @Bean fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { - return http - .csrf { csrf -> csrf.disable() } - .authorizeRequests { auth -> auth.anyRequest().permitAll() } - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) - .sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .httpBasic(Customizer.withDefaults()) - .build() + return http.csrf { csrf -> csrf.disable() } + .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) + .sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .httpBasic(Customizer.withDefaults()).build() } @Bean diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index a83148f8..2713027b 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -1,19 +1,25 @@ package pt.up.fe.ni.website.backend.controller +import org.springframework.security.access.annotation.Secured +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.Authentication import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import pt.up.fe.ni.website.backend.service.AuthService @RestController +@RequestMapping("/auth") class AuthController(val authService: AuthService) { + @PostMapping + fun getToken(authentication: Authentication): Map { + val token = authService.authenticate(authentication) + return mapOf("token" to token) + } - @PostMapping("/token") - fun getToken(authentication: Authentication) = authService.generateToken(authentication) - - // Just a locked endpoint to test the auth... Delete this when authorization is properly handled (permissions) - @GetMapping("/secret") + @GetMapping + @PreAuthorize("isAuthenticated()") fun authCheck(): Map { return mapOf("authenticated" to "true") } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt index 4cde8666..cd7ce218 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestControllerAdvice import javax.validation.ConstraintViolationException +import org.springframework.security.access.AccessDeniedException data class SimpleError( val message: String, @@ -73,6 +74,12 @@ class ErrorController : ErrorController { return wrapSimpleError(e.message ?: "element not found") } + @ExceptionHandler(AccessDeniedException::class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + fun unauthorized(e: AccessDeniedException): CustomError { + return wrapSimpleError(e.message ?: "unauthorized") + } + @ExceptionHandler(Exception::class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) fun unexpectedError(e: Exception): CustomError { diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt index 7d2dfa66..ec4c0741 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -1,29 +1,42 @@ package pt.up.fe.ni.website.backend.service -import java.time.Instant -import java.util.stream.Collectors import org.springframework.security.core.Authentication import org.springframework.security.core.GrantedAuthority import org.springframework.security.oauth2.jwt.JwtClaimsSet import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.JwtEncoderParameters import org.springframework.stereotype.Service +import java.time.Instant +import java.time.Period +import java.util.stream.Collectors @Service class AuthService(val encoder: JwtEncoder) { - fun generateToken(authentication: Authentication): String { + fun authenticate(authentication: Authentication): String { + if (!okCredentials(authentication)) { + throw Error("Invalid credentials") + } + return generateToken(authentication) + } + + // When accounts are ready, check against the database for the user + private fun okCredentials(authentication: Authentication) = true + + private fun generateToken(authentication: Authentication): String { val scope = authentication - .authorities - .stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.joining(" ")) + .authorities + .stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(" ")) + val currentInstant = Instant.now() val claims = JwtClaimsSet - .builder() - .issuer("self") - .issuedAt(Instant.now()) - .subject(authentication.name) - .claim("scope", scope) - .build() + .builder() + .issuer("self") + .issuedAt(currentInstant) + .expiresAt(currentInstant.plus(Period.ofDays(1))) + .subject(authentication.name) + .claim("scope", scope) + .build() return encoder.encode(JwtEncoderParameters.from(claims)).tokenValue } } From edc8ff1bb78f52b4e1a58f0d6a8e2a4bf79fba79 Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Wed, 23 Nov 2022 16:12:08 +0000 Subject: [PATCH 07/15] Improve error handling for invalid auth requests --- .../backend/controller/AuthController.kt | 7 +++- .../backend/controller/ErrorController.kt | 36 +++++++++++-------- .../ni/website/backend/service/AuthService.kt | 4 ++- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index 2713027b..df74b6fd 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -1,5 +1,6 @@ package pt.up.fe.ni.website.backend.controller +import org.springframework.http.HttpStatus import org.springframework.security.access.annotation.Secured import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.Authentication @@ -7,13 +8,17 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException import pt.up.fe.ni.website.backend.service.AuthService @RestController @RequestMapping("/auth") class AuthController(val authService: AuthService) { @PostMapping - fun getToken(authentication: Authentication): Map { + fun getToken(authentication: Authentication?): Map { + if (authentication == null) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "No credentials were provided") + } val token = authService.authenticate(authentication) return mapOf("token" to token) } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt index cd7ce218..507bd456 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt @@ -12,11 +12,13 @@ import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestControllerAdvice import javax.validation.ConstraintViolationException import org.springframework.security.access.AccessDeniedException +import org.springframework.web.server.ResponseStatusException +import javax.servlet.http.HttpServletResponse data class SimpleError( - val message: String, - val param: String? = null, - val value: Any? = null + val message: String, + val param: String? = null, + val value: Any? = null ) data class CustomError(val errors: List) @@ -35,11 +37,11 @@ class ErrorController : ErrorController { val errors = mutableListOf() e.constraintViolations.forEach { violation -> errors.add( - SimpleError( - violation.message, - violation.propertyPath.toString(), - violation.invalidValue - ) + SimpleError( + violation.message, + violation.propertyPath.toString(), + violation.invalidValue + ) ) } return CustomError(errors) @@ -52,15 +54,15 @@ class ErrorController : ErrorController { is InvalidFormatException -> { val type = cause.targetType.simpleName.lowercase() return wrapSimpleError( - "must be $type", - value = cause.value + "must be $type", + value = cause.value ) } is MissingKotlinParameterException -> { return wrapSimpleError( - "required", - param = cause.parameter.name + "required", + param = cause.parameter.name ) } } @@ -80,14 +82,20 @@ class ErrorController : ErrorController { return wrapSimpleError(e.message ?: "unauthorized") } + @ExceptionHandler(ResponseStatusException::class) + fun expectedError(e: ResponseStatusException, response: HttpServletResponse): CustomError { + response.status = e.status.value() + return wrapSimpleError(e.reason ?: (e.message)) + } + @ExceptionHandler(Exception::class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) fun unexpectedError(e: Exception): CustomError { System.err.println(e) - return wrapSimpleError("unexpected error") + return wrapSimpleError("unexpected error: " + e.message) } fun wrapSimpleError(msg: String, param: String? = null, value: Any? = null) = CustomError( - mutableListOf(SimpleError(msg, param, value)) + mutableListOf(SimpleError(msg, param, value)) ) } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt index ec4c0741..fb5d75ed 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -1,11 +1,13 @@ package pt.up.fe.ni.website.backend.service +import org.springframework.http.HttpStatus import org.springframework.security.core.Authentication import org.springframework.security.core.GrantedAuthority import org.springframework.security.oauth2.jwt.JwtClaimsSet import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.JwtEncoderParameters import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException import java.time.Instant import java.time.Period import java.util.stream.Collectors @@ -14,7 +16,7 @@ import java.util.stream.Collectors class AuthService(val encoder: JwtEncoder) { fun authenticate(authentication: Authentication): String { if (!okCredentials(authentication)) { - throw Error("Invalid credentials") + throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Invalid credentials") } return generateToken(authentication) } From a335f95e0b4f214e1ff8211c98089cec1a548bf9 Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Thu, 24 Nov 2022 11:44:51 +0000 Subject: [PATCH 08/15] Authenticate against the database --- .../up/fe/ni/website/backend/config/AuthConfig.kt | 7 +++++++ .../website/backend/controller/AuthController.kt | 4 ++-- .../pt/up/fe/ni/website/backend/model/Account.kt | 10 ++++++---- .../backend/model/constants/AccountConstants.kt | 5 +++++ .../fe/ni/website/backend/model/dto/AccountDto.kt | 5 +++-- .../ni/website/backend/service/AccountService.kt | 7 ++++++- .../fe/ni/website/backend/service/AuthService.kt | 15 +++++++++++---- 7 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt index e5220647..9bb418ff 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt @@ -11,6 +11,8 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.NimbusJwtDecoder @@ -39,4 +41,9 @@ class AuthConfig(val rsaKeys: RSAKeyProperties) { val jwt = RSAKey.Builder(rsaKeys::publicKey.get()).privateKey(rsaKeys::privateKey.get()).build() return NimbusJwtEncoder(ImmutableJWKSet(JWKSet(jwt))) } + + @Bean + fun passwordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index 15f1ec4e..8493f6b6 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -13,8 +13,8 @@ import pt.up.fe.ni.website.backend.service.AuthService @RestController @RequestMapping("/auth") class AuthController(val authService: AuthService) { - @PostMapping - fun getToken(authentication: Authentication?): Map { + @PostMapping("/new_token") + fun getNewToken(authentication: Authentication?): Map { if (authentication == null) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "No credentials were provided") } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt index 5833e958..2d5141e3 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -21,16 +21,18 @@ import pt.up.fe.ni.website.backend.model.constants.AccountConstants as Constants @Entity class Account( - @JsonProperty(required = true) - @field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize) - var name: String, - @JsonProperty(required = true) @Column(unique = true) @field:NotEmpty @field:Email var email: String, + @field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize) + var name: String, + + @field:Size(min = Constants.Password.minSize, max = Constants.Password.maxSize) + var password: String, + @field:Size(min = Constants.Bio.minSize, max = Constants.Bio.maxSize) var bio: String?, diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/AccountConstants.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/AccountConstants.kt index f6b3995d..15c19403 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/AccountConstants.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/AccountConstants.kt @@ -10,4 +10,9 @@ object AccountConstants { const val minSize = 5 const val maxSize = 500 } + + object Password { + const val minSize = 8 + const val maxSize = 100 + } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt index 0d65b6de..a10a9e4a 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt @@ -4,12 +4,13 @@ import pt.up.fe.ni.website.backend.model.Account import java.util.Date class AccountDto( - val name: String, val email: String, + val password: String, + val name: String?, val bio: String?, val birthDate: Date?, val photoPath: String?, val linkedin: String?, val github: String?, - val websites: List + val websites: List? ) : Dto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt index 2974e27b..49ed885b 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt @@ -1,13 +1,14 @@ package pt.up.fe.ni.website.backend.service import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import pt.up.fe.ni.website.backend.model.Account import pt.up.fe.ni.website.backend.model.dto.AccountDto import pt.up.fe.ni.website.backend.repository.AccountRepository @Service -class AccountService(private val repository: AccountRepository) { +class AccountService(private val repository: AccountRepository, private val encoder: PasswordEncoder) { fun getAllAccounts(): List = repository.findAll().toList() fun createAccount(dto: AccountDto): Account { @@ -16,9 +17,13 @@ class AccountService(private val repository: AccountRepository) { } val account = dto.create() + account.password = encoder.encode(dto.password) return repository.save(account) } fun getAccountById(id: Long): Account = repository.findByIdOrNull(id) ?: throw NoSuchElementException("account not found with id $id") + + fun getAccountByEmail(email: String): Account = repository.findByEmail(email) + ?: throw NoSuchElementException("account not found with email $email") } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt index 3e34fca6..e23980d4 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -3,6 +3,7 @@ package pt.up.fe.ni.website.backend.service import org.springframework.http.HttpStatus import org.springframework.security.core.Authentication import org.springframework.security.core.GrantedAuthority +import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.oauth2.jwt.JwtClaimsSet import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.JwtEncoderParameters @@ -13,7 +14,11 @@ import java.time.Period import java.util.stream.Collectors @Service -class AuthService(val encoder: JwtEncoder) { +class AuthService( + val accountService: AccountService, + val jwtEncoder: JwtEncoder, + private val passwordEncoder: PasswordEncoder +) { fun authenticate(authentication: Authentication): String { if (!okCredentials(authentication)) { throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Invalid credentials") @@ -21,8 +26,10 @@ class AuthService(val encoder: JwtEncoder) { return generateToken(authentication) } - // When accounts are ready, check against the database for the user - private fun okCredentials(authentication: Authentication) = true + private fun okCredentials(authentication: Authentication): Boolean { + val account = accountService.getAccountByEmail(authentication.name) + return passwordEncoder.matches(authentication.credentials.toString(), account.password) + } private fun generateToken(authentication: Authentication): String { val scope = authentication @@ -39,6 +46,6 @@ class AuthService(val encoder: JwtEncoder) { .subject(authentication.name) .claim("scope", scope) .build() - return encoder.encode(JwtEncoderParameters.from(claims)).tokenValue + return jwtEncoder.encode(JwtEncoderParameters.from(claims)).tokenValue } } From f98fb203b9e63ced6289c3e253e6b3d8bd0166a9 Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Thu, 24 Nov 2022 13:57:55 +0000 Subject: [PATCH 09/15] Ignore password while serializaing json --- .../pt/up/fe/ni/website/backend/controller/AuthController.kt | 2 +- src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt | 2 ++ .../kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt | 2 +- .../fe/ni/website/backend/controller/AccountControllerTest.kt | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index 8493f6b6..e56015c9 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -13,7 +13,7 @@ import pt.up.fe.ni.website.backend.service.AuthService @RestController @RequestMapping("/auth") class AuthController(val authService: AuthService) { - @PostMapping("/new_token") + @PostMapping("/new") fun getNewToken(authentication: Authentication?): Map { if (authentication == null) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "No credentials were provided") diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt index 2d5141e3..167930c3 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -1,5 +1,6 @@ package pt.up.fe.ni.website.backend.model +import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonProperty import org.hibernate.validator.constraints.URL import pt.up.fe.ni.website.backend.annotations.validation.NullOrNotBlank @@ -30,6 +31,7 @@ class Account( @field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize) var name: String, + @JsonIgnore @field:Size(min = Constants.Password.minSize, max = Constants.Password.maxSize) var password: String, diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt index a10a9e4a..b8101e2a 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt @@ -6,7 +6,7 @@ import java.util.Date class AccountDto( val email: String, val password: String, - val name: String?, + val name: String, val bio: String?, val birthDate: Date?, val photoPath: String?, diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt index 8eaff2be..554dacfd 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt @@ -37,6 +37,7 @@ class AccountControllerTest @Autowired constructor( val testAccount = Account( "Test Account", "test_account@test.com", + "test_password", "This is a test account", TestUtils.createDate(2001, Calendar.JULY, 28), "https://test-photo.com", @@ -56,6 +57,7 @@ class AccountControllerTest @Autowired constructor( Account( "Test Account 2", "test_account2@test.com", + "test_password", null, null, null, From a9bc564bfb771e378a0373ae1a1fab64df7c0972 Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Tue, 29 Nov 2022 11:16:47 +0000 Subject: [PATCH 10/15] Fix existing tests; fix token request --- .../backend/controller/AuthController.kt | 12 ++--- .../up/fe/ni/website/backend/model/Account.kt | 9 ++-- .../ni/website/backend/model/dto/LoginDto.kt | 6 +++ .../ni/website/backend/service/AuthService.kt | 16 ++++--- .../controller/AccountControllerTest.kt | 44 +++++++++++++++++-- 5 files changed, 64 insertions(+), 23 deletions(-) create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/LoginDto.kt diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index e56015c9..0a1d3faf 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -1,24 +1,20 @@ package pt.up.fe.ni.website.backend.controller -import org.springframework.http.HttpStatus import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.security.core.Authentication import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -import org.springframework.web.server.ResponseStatusException +import pt.up.fe.ni.website.backend.model.dto.LoginDto import pt.up.fe.ni.website.backend.service.AuthService @RestController @RequestMapping("/auth") class AuthController(val authService: AuthService) { @PostMapping("/new") - fun getNewToken(authentication: Authentication?): Map { - if (authentication == null) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "No credentials were provided") - } - val token = authService.authenticate(authentication) + fun getNewToken(@RequestBody loginDto: LoginDto): Map { + val token = authService.authenticate(loginDto.email, loginDto.password) return mapOf("token" to token) } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt index 167930c3..4140d37c 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -1,6 +1,5 @@ package pt.up.fe.ni.website.backend.model -import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonProperty import org.hibernate.validator.constraints.URL import pt.up.fe.ni.website.backend.annotations.validation.NullOrNotBlank @@ -22,16 +21,16 @@ import pt.up.fe.ni.website.backend.model.constants.AccountConstants as Constants @Entity class Account( + @field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize) + var name: String, + @JsonProperty(required = true) @Column(unique = true) @field:NotEmpty @field:Email var email: String, - @field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize) - var name: String, - - @JsonIgnore + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY, required = true) @field:Size(min = Constants.Password.minSize, max = Constants.Password.maxSize) var password: String, diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/LoginDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/LoginDto.kt new file mode 100644 index 00000000..59ed4d0b --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/LoginDto.kt @@ -0,0 +1,6 @@ +package pt.up.fe.ni.website.backend.model.dto + +class LoginDto( + val email: String, + val password: String +) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt index e23980d4..ddc338b0 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -1,8 +1,10 @@ package pt.up.fe.ni.website.backend.service import org.springframework.http.HttpStatus +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.Authentication import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.oauth2.jwt.JwtClaimsSet import org.springframework.security.oauth2.jwt.JwtEncoder @@ -19,18 +21,18 @@ class AuthService( val jwtEncoder: JwtEncoder, private val passwordEncoder: PasswordEncoder ) { - fun authenticate(authentication: Authentication): String { - if (!okCredentials(authentication)) { + fun authenticate(email: String, password: String): String { + val account = accountService.getAccountByEmail(email) + if (!passwordEncoder.matches(password, account.password)) { throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Invalid credentials") } + val authorities = listOf("BOARD", "MEMBER").stream() // TODO: get roles from account + .map { role -> SimpleGrantedAuthority("ROLE_$role") } + .collect(Collectors.toList()) + val authentication = UsernamePasswordAuthenticationToken(email, password, authorities) return generateToken(authentication) } - private fun okCredentials(authentication: Authentication): Boolean { - val account = accountService.getAccountByEmail(authentication.name) - return passwordEncoder.matches(authentication.credentials.toString(), account.password) - } - private fun generateToken(authentication: Authentication): String { val scope = authentication .authorities diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt index 554dacfd..853ad019 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt @@ -134,7 +134,7 @@ class AccountControllerTest @Autowired constructor( fun `should create the account`() { mockMvc.post("/accounts/new") { contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(testAccount) + content = testAccount.toJson() }.andExpect { status { isOk() } content { contentType(MediaType.APPLICATION_JSON) } @@ -164,6 +164,7 @@ class AccountControllerTest @Autowired constructor( requiredFields = mapOf( "name" to testAccount.name, "email" to testAccount.email, + "password" to testAccount.password, "websites" to emptyList() ) ) @@ -204,6 +205,23 @@ class AccountControllerTest @Autowired constructor( fun `should be a valid email`() = validationTester.isEmail() } + @Nested + @DisplayName("password") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class PasswordValidation { + @BeforeAll + fun setParam() { + validationTester.param = "password" + } + + @Test + fun `should be required`() = validationTester.isRequired() + + @Test + @DisplayName("size should be between ${Constants.Password.minSize} and ${Constants.Password.maxSize}()") + fun size() = validationTester.hasSizeBetween(Constants.Password.minSize, Constants.Password.maxSize) + } + @Nested @DisplayName("bio") @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -294,6 +312,7 @@ class AccountControllerTest @Autowired constructor( mapOf( "name" to testAccount.name, "email" to testAccount.email, + "password" to testAccount.password, "websites" to listOf(params) ) ) @@ -353,14 +372,15 @@ class AccountControllerTest @Autowired constructor( @Test fun `should fail to create account with existing email`() { + println("testAccount: ${objectMapper.writeValueAsString(testAccount)}") mockMvc.post("/accounts/new") { contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(testAccount) + content = testAccount.toJson() }.andExpect { status { isOk() } } mockMvc.post("/accounts/new") { contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(testAccount) + content = testAccount.toJson() }.andExpect { status { isUnprocessableEntity() } content { contentType(MediaType.APPLICATION_JSON) } @@ -375,4 +395,22 @@ class AccountControllerTest @Autowired constructor( // objectMapper adds quotes to the date, so remove them return quotedDate.substring(1, quotedDate.length - 1) } + + fun Account?.toJson(): String { + // password is ignored on serialization, so add it manually + // for account creation test cases + return objectMapper.writeValueAsString( + mapOf( + "name" to this?.name, + "email" to this?.email, + "password" to this?.password, + "bio" to this?.bio, + "birthDate" to this?.birthDate.toJson(), + "photoPath" to this?.photoPath, + "linkedin" to this?.linkedin, + "github" to this?.github, + "websites" to this?.websites + ) + ) + } } From fc4df18dda085cbba291180571a7279e40654ccc Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Tue, 29 Nov 2022 11:52:32 +0000 Subject: [PATCH 11/15] Make expire days configurable --- .../ni/website/backend/BackendApplication.kt | 4 ++-- .../ni/website/backend/config/AuthConfig.kt | 6 +++--- ...yProperties.kt => AuthConfigProperties.kt} | 8 ++++--- .../backend/controller/AuthController.kt | 10 +++++---- .../ni/website/backend/service/AuthService.kt | 21 +++++++++++++------ src/main/resources/application.properties | 10 +++++---- 6 files changed, 37 insertions(+), 22 deletions(-) rename src/main/kotlin/pt/up/fe/ni/website/backend/config/{RSAKeyProperties.kt => AuthConfigProperties.kt} (63%) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt index 7d195698..b8ebb7dc 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt @@ -4,10 +4,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication import org.springframework.data.jpa.repository.config.EnableJpaAuditing -import pt.up.fe.ni.website.backend.config.RSAKeyProperties +import pt.up.fe.ni.website.backend.config.AuthConfigProperties @SpringBootApplication -@EnableConfigurationProperties(RSAKeyProperties::class) +@EnableConfigurationProperties(AuthConfigProperties::class) @EnableJpaAuditing class BackendApplication diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt index 9bb418ff..26e6ec53 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt @@ -22,7 +22,7 @@ import org.springframework.security.web.SecurityFilterChain @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) -class AuthConfig(val rsaKeys: RSAKeyProperties) { +class AuthConfig(val authConfigProperties: AuthConfigProperties) { @Bean fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { return http.csrf { csrf -> csrf.disable() } @@ -33,12 +33,12 @@ class AuthConfig(val rsaKeys: RSAKeyProperties) { @Bean fun jwtDecoder(): JwtDecoder { - return NimbusJwtDecoder.withPublicKey(rsaKeys::publicKey.get()).build() + return NimbusJwtDecoder.withPublicKey(authConfigProperties::publicKey.get()).build() } @Bean fun jwtEncoder(): JwtEncoder { - val jwt = RSAKey.Builder(rsaKeys::publicKey.get()).privateKey(rsaKeys::privateKey.get()).build() + val jwt = RSAKey.Builder(authConfigProperties::publicKey.get()).privateKey(authConfigProperties::privateKey.get()).build() return NimbusJwtEncoder(ImmutableJWKSet(JWKSet(jwt))) } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/RSAKeyProperties.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfigProperties.kt similarity index 63% rename from src/main/kotlin/pt/up/fe/ni/website/backend/config/RSAKeyProperties.kt rename to src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfigProperties.kt index 482f549e..1380a924 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/config/RSAKeyProperties.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfigProperties.kt @@ -6,8 +6,10 @@ import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPublicKey @ConstructorBinding -@ConfigurationProperties(prefix = "rsa") -data class RSAKeyProperties( +@ConfigurationProperties(prefix = "auth") +data class AuthConfigProperties( val publicKey: RSAPublicKey, - val privateKey: RSAPrivateKey + val privateKey: RSAPrivateKey, + val jwtAccessExpirationMinutes: Long, + val jwtRefreshExpirationDays: Long ) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index 0a1d3faf..33eb4966 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -14,13 +14,15 @@ import pt.up.fe.ni.website.backend.service.AuthService class AuthController(val authService: AuthService) { @PostMapping("/new") fun getNewToken(@RequestBody loginDto: LoginDto): Map { - val token = authService.authenticate(loginDto.email, loginDto.password) - return mapOf("token" to token) + val authentication = authService.authenticate(loginDto.email, loginDto.password) + val accessToken = authService.generateAccessToken(authentication) + return mapOf("access_token" to accessToken) } @GetMapping @PreAuthorize("isAuthenticated()") - fun authCheck(): Map { - return mapOf("authenticated" to "true") + fun checkAuthentication(): Map { + val account = authService.getAuthenticatedAccount() + return mapOf("authenticated_user" to account.email) } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt index ddc338b0..809c0bf3 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -5,23 +5,28 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import org.springframework.security.core.Authentication import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.oauth2.jwt.JwtClaimsSet import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.JwtEncoderParameters import org.springframework.stereotype.Service import org.springframework.web.server.ResponseStatusException +import pt.up.fe.ni.website.backend.config.AuthConfigProperties +import pt.up.fe.ni.website.backend.model.Account +import java.time.Duration import java.time.Instant -import java.time.Period +import java.time.temporal.ChronoUnit import java.util.stream.Collectors @Service class AuthService( val accountService: AccountService, + val authConfigProperties: AuthConfigProperties, val jwtEncoder: JwtEncoder, private val passwordEncoder: PasswordEncoder ) { - fun authenticate(email: String, password: String): String { + fun authenticate(email: String, password: String): Authentication { val account = accountService.getAccountByEmail(email) if (!passwordEncoder.matches(password, account.password)) { throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Invalid credentials") @@ -29,11 +34,10 @@ class AuthService( val authorities = listOf("BOARD", "MEMBER").stream() // TODO: get roles from account .map { role -> SimpleGrantedAuthority("ROLE_$role") } .collect(Collectors.toList()) - val authentication = UsernamePasswordAuthenticationToken(email, password, authorities) - return generateToken(authentication) + return UsernamePasswordAuthenticationToken(email, password, authorities) } - private fun generateToken(authentication: Authentication): String { + fun generateAccessToken(authentication: Authentication): String { val scope = authentication .authorities .stream() @@ -44,10 +48,15 @@ class AuthService( .builder() .issuer("self") .issuedAt(currentInstant) - .expiresAt(currentInstant.plus(Period.ofDays(1))) + .expiresAt(currentInstant.plus(Duration.of(authConfigProperties.jwtAccessExpirationMinutes, ChronoUnit.MINUTES))) .subject(authentication.name) .claim("scope", scope) .build() return jwtEncoder.encode(JwtEncoderParameters.from(claims)).tokenValue } + + fun getAuthenticatedAccount(): Account { + val authentication = SecurityContextHolder.getContext().authentication + return accountService.getAccountByEmail(authentication.name) + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index da6748f5..a35ce152 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -14,11 +14,13 @@ spring.h2.console.path=/h2 # API Settings server.error.whitelabel.enabled=false -# RSA Config -rsa.private-key=classpath:certs/private.pem -rsa.public-key=classpath:certs/public.pem - # Jackson spring.jackson.default-property-inclusion=non_null spring.jackson.deserialization.fail-on-null-creator-properties=true spring.jackson.date-format=dd-MM-yyyy + +# Auth Config +auth.private-key=classpath:certs/private.pem +auth.public-key=classpath:certs/public.pem +auth.jwt-access-expiration-minutes=15 +auth.jwt-refresh-expiration-days=7 From 9573812e1f6afb76ebb3a67af1e536013dcfe3c6 Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Tue, 29 Nov 2022 16:49:59 +0000 Subject: [PATCH 12/15] Implement refresh tokens --- .../ni/website/backend/BackendApplication.kt | 2 +- .../backend/config/{ => auth}/AuthConfig.kt | 25 ++++++++++--- .../config/{ => auth}/AuthConfigProperties.kt | 2 +- .../backend/controller/AuthController.kt | 17 ++++++++- .../ni/website/backend/model/dto/LoginDto.kt | 6 ---- .../ni/website/backend/service/AuthService.kt | 35 ++++++++++++++----- src/main/resources/application.properties | 2 +- 7 files changed, 66 insertions(+), 23 deletions(-) rename src/main/kotlin/pt/up/fe/ni/website/backend/config/{ => auth}/AuthConfig.kt (69%) rename src/main/kotlin/pt/up/fe/ni/website/backend/config/{ => auth}/AuthConfigProperties.kt (90%) delete mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/LoginDto.kt diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt index b8ebb7dc..de7708aa 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt @@ -4,7 +4,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication import org.springframework.data.jpa.repository.config.EnableJpaAuditing -import pt.up.fe.ni.website.backend.config.AuthConfigProperties +import pt.up.fe.ni.website.backend.config.auth.AuthConfigProperties @SpringBootApplication @EnableConfigurationProperties(AuthConfigProperties::class) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt similarity index 69% rename from src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt rename to src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt index 26e6ec53..7094c97e 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfig.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt @@ -1,11 +1,10 @@ -package pt.up.fe.ni.website.backend.config +package pt.up.fe.ni.website.backend.config.auth import com.nimbusds.jose.jwk.JWKSet import com.nimbusds.jose.jwk.RSAKey import com.nimbusds.jose.jwk.source.ImmutableJWKSet import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.security.config.Customizer 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.EnableWebSecurity @@ -18,17 +17,20 @@ import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.NimbusJwtDecoder import org.springframework.security.oauth2.jwt.NimbusJwtEncoder import org.springframework.security.web.SecurityFilterChain +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.UrlBasedCorsConfigurationSource +import org.springframework.web.filter.CorsFilter @Configuration @EnableWebSecurity -@EnableGlobalMethodSecurity(prePostEnabled = true) +@EnableGlobalMethodSecurity(securedEnabled = false, prePostEnabled = true) class AuthConfig(val authConfigProperties: AuthConfigProperties) { @Bean fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { - return http.csrf { csrf -> csrf.disable() } + return http.csrf { csrf -> csrf.disable() }.cors().and() .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) .sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .httpBasic(Customizer.withDefaults()).build() + .httpBasic().disable().build() } @Bean @@ -46,4 +48,17 @@ class AuthConfig(val authConfigProperties: AuthConfigProperties) { fun passwordEncoder(): PasswordEncoder { return BCryptPasswordEncoder() } + + @Bean + fun corsFilter(): CorsFilter { + // TODO: This is a temporary solution. We should use a proper CORS filter. + val source = UrlBasedCorsConfigurationSource() + val config = CorsConfiguration() + config.allowCredentials = true + config.addAllowedOrigin("*") + config.addAllowedHeader("*") + config.addAllowedMethod("*") + source.registerCorsConfiguration("/**", config) + return CorsFilter(source) + } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfigProperties.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfigProperties.kt similarity index 90% rename from src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfigProperties.kt rename to src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfigProperties.kt index 1380a924..a03014ff 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/config/AuthConfigProperties.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfigProperties.kt @@ -1,4 +1,4 @@ -package pt.up.fe.ni.website.backend.config +package pt.up.fe.ni.website.backend.config.auth import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConstructorBinding diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index 33eb4966..aeef6790 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -6,9 +6,17 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -import pt.up.fe.ni.website.backend.model.dto.LoginDto import pt.up.fe.ni.website.backend.service.AuthService +data class LoginDto( + val email: String, + val password: String +) + +data class TokenDto( + val token: String +) + @RestController @RequestMapping("/auth") class AuthController(val authService: AuthService) { @@ -16,6 +24,13 @@ class AuthController(val authService: AuthService) { fun getNewToken(@RequestBody loginDto: LoginDto): Map { val authentication = authService.authenticate(loginDto.email, loginDto.password) val accessToken = authService.generateAccessToken(authentication) + val refreshToken = authService.generateRefreshToken(authentication) + return mapOf("access_token" to accessToken, "refresh_token" to refreshToken) + } + + @PostMapping("/refresh") + fun refreshAccessToken(@RequestBody tokenDto: TokenDto): Map { + val accessToken = authService.refreshToken(tokenDto.token) return mapOf("access_token" to accessToken) } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/LoginDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/LoginDto.kt deleted file mode 100644 index 59ed4d0b..00000000 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/LoginDto.kt +++ /dev/null @@ -1,6 +0,0 @@ -package pt.up.fe.ni.website.backend.model.dto - -class LoginDto( - val email: String, - val password: String -) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt index 809c0bf3..206815d2 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -8,15 +8,15 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.oauth2.jwt.JwtClaimsSet +import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.JwtEncoderParameters import org.springframework.stereotype.Service import org.springframework.web.server.ResponseStatusException -import pt.up.fe.ni.website.backend.config.AuthConfigProperties +import pt.up.fe.ni.website.backend.config.auth.AuthConfigProperties import pt.up.fe.ni.website.backend.model.Account import java.time.Duration import java.time.Instant -import java.time.temporal.ChronoUnit import java.util.stream.Collectors @Service @@ -24,6 +24,7 @@ class AuthService( val accountService: AccountService, val authConfigProperties: AuthConfigProperties, val jwtEncoder: JwtEncoder, + val jwtDecoder: JwtDecoder, private val passwordEncoder: PasswordEncoder ) { fun authenticate(email: String, password: String): Authentication { @@ -38,6 +39,29 @@ class AuthService( } fun generateAccessToken(authentication: Authentication): String { + return generateToken(authentication, Duration.ofMinutes(authConfigProperties.jwtAccessExpirationMinutes)) + } + + fun generateRefreshToken(authentication: Authentication): String { + return generateToken(authentication, Duration.ofDays(authConfigProperties.jwtRefreshExpirationDays)) + } + + fun refreshToken(refreshToken: String): String { + val jwt = jwtDecoder.decode(refreshToken) + if (jwt.expiresAt?.isBefore(Instant.now()) != false) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token has expired") + } + val account = accountService.getAccountByEmail(jwt.subject) + val authentication = authenticate(account.email, account.password) + return generateAccessToken(authentication) + } + + fun getAuthenticatedAccount(): Account { + val authentication = SecurityContextHolder.getContext().authentication + return accountService.getAccountByEmail(authentication.name) + } + + private fun generateToken(authentication: Authentication, expiration: Duration): String { val scope = authentication .authorities .stream() @@ -48,15 +72,10 @@ class AuthService( .builder() .issuer("self") .issuedAt(currentInstant) - .expiresAt(currentInstant.plus(Duration.of(authConfigProperties.jwtAccessExpirationMinutes, ChronoUnit.MINUTES))) + .expiresAt(currentInstant.plus(expiration)) .subject(authentication.name) .claim("scope", scope) .build() return jwtEncoder.encode(JwtEncoderParameters.from(claims)).tokenValue } - - fun getAuthenticatedAccount(): Account { - val authentication = SecurityContextHolder.getContext().authentication - return accountService.getAccountByEmail(authentication.name) - } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a35ce152..57d62dff 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -22,5 +22,5 @@ spring.jackson.date-format=dd-MM-yyyy # Auth Config auth.private-key=classpath:certs/private.pem auth.public-key=classpath:certs/public.pem -auth.jwt-access-expiration-minutes=15 +auth.jwt-access-expiration-minutes=60 auth.jwt-refresh-expiration-days=7 From 550d5a453e66465411a32ba5b4a6502b82ebaebc Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Tue, 29 Nov 2022 17:28:47 +0000 Subject: [PATCH 13/15] Fix role checking --- .../ni/website/backend/config/auth/AuthConfig.kt | 15 ++++++++++++--- .../website/backend/controller/AuthController.kt | 8 +++++--- .../fe/ni/website/backend/service/AuthService.kt | 10 +++++----- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt index 7094c97e..e19f5cd8 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt @@ -8,7 +8,6 @@ import org.springframework.context.annotation.Configuration 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.EnableWebSecurity -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder @@ -16,6 +15,8 @@ import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.NimbusJwtDecoder import org.springframework.security.oauth2.jwt.NimbusJwtEncoder +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter import org.springframework.security.web.SecurityFilterChain import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.UrlBasedCorsConfigurationSource @@ -23,12 +24,12 @@ import org.springframework.web.filter.CorsFilter @Configuration @EnableWebSecurity -@EnableGlobalMethodSecurity(securedEnabled = false, prePostEnabled = true) +@EnableGlobalMethodSecurity(prePostEnabled = true) class AuthConfig(val authConfigProperties: AuthConfigProperties) { @Bean fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { return http.csrf { csrf -> csrf.disable() }.cors().and() - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) + .oauth2ResourceServer().jwt().jwtAuthenticationConverter(rolesConverter()).and().and() .sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .httpBasic().disable().build() } @@ -61,4 +62,12 @@ class AuthConfig(val authConfigProperties: AuthConfigProperties) { source.registerCorsConfiguration("/**", config) return CorsFilter(source) } + + fun rolesConverter(): JwtAuthenticationConverter? { + val authoritiesConverter = JwtGrantedAuthoritiesConverter() + authoritiesConverter.setAuthorityPrefix("ROLE_") + val converter = JwtAuthenticationConverter() + converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter) + return converter + } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index aeef6790..ebc6c840 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -1,12 +1,14 @@ package pt.up.fe.ni.website.backend.controller import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import pt.up.fe.ni.website.backend.service.AuthService +import javax.servlet.http.HttpServletRequest data class LoginDto( val email: String, @@ -19,7 +21,7 @@ data class TokenDto( @RestController @RequestMapping("/auth") -class AuthController(val authService: AuthService) { +class AuthController(val authService: AuthService, val jwtDecoder: JwtDecoder) { @PostMapping("/new") fun getNewToken(@RequestBody loginDto: LoginDto): Map { val authentication = authService.authenticate(loginDto.email, loginDto.password) @@ -35,8 +37,8 @@ class AuthController(val authService: AuthService) { } @GetMapping - @PreAuthorize("isAuthenticated()") - fun checkAuthentication(): Map { + @PreAuthorize("hasRole('MEMBER')") + fun checkAuthentication(request: HttpServletRequest): Map { val account = authService.getAuthenticatedAccount() return mapOf("authenticated_user" to account.email) } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt index 206815d2..762a3a32 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -33,7 +33,7 @@ class AuthService( throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Invalid credentials") } val authorities = listOf("BOARD", "MEMBER").stream() // TODO: get roles from account - .map { role -> SimpleGrantedAuthority("ROLE_$role") } + .map { role -> SimpleGrantedAuthority(role) } .collect(Collectors.toList()) return UsernamePasswordAuthenticationToken(email, password, authorities) } @@ -43,7 +43,7 @@ class AuthService( } fun generateRefreshToken(authentication: Authentication): String { - return generateToken(authentication, Duration.ofDays(authConfigProperties.jwtRefreshExpirationDays)) + return generateToken(authentication, Duration.ofDays(authConfigProperties.jwtRefreshExpirationDays), true) } fun refreshToken(refreshToken: String): String { @@ -61,9 +61,9 @@ class AuthService( return accountService.getAccountByEmail(authentication.name) } - private fun generateToken(authentication: Authentication, expiration: Duration): String { - val scope = authentication - .authorities + private fun generateToken(authentication: Authentication, expiration: Duration, isRefresh: Boolean = false): String { + val roles = if (isRefresh) emptyList() else authentication.authorities + val scope = roles .stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(" ")) From cdf62b6c651aa9444f1fb4c3e7a97bf29830cb30 Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Tue, 29 Nov 2022 18:35:16 +0000 Subject: [PATCH 14/15] Add tests for auth routes --- .../backend/controller/AuthController.kt | 11 +- .../ni/website/backend/service/AuthService.kt | 46 +++-- .../controller/AccountControllerTest.kt | 1 + .../backend/controller/AuthControllerTest.kt | 183 ++++++++++++++++++ .../backend/controller/EventControllerTest.kt | 1 + .../backend/controller/PostControllerTest.kt | 2 +- .../controller/ProjectControllerTest.kt | 1 + .../{controller => utils}/ValidationTester.kt | 2 +- 8 files changed, 220 insertions(+), 27 deletions(-) create mode 100644 src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt rename src/test/kotlin/pt/up/fe/ni/website/backend/{controller => utils}/ValidationTester.kt (99%) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index ebc6c840..0ff40c20 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -1,7 +1,6 @@ package pt.up.fe.ni.website.backend.controller import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -21,18 +20,18 @@ data class TokenDto( @RestController @RequestMapping("/auth") -class AuthController(val authService: AuthService, val jwtDecoder: JwtDecoder) { +class AuthController(val authService: AuthService) { @PostMapping("/new") fun getNewToken(@RequestBody loginDto: LoginDto): Map { - val authentication = authService.authenticate(loginDto.email, loginDto.password) - val accessToken = authService.generateAccessToken(authentication) - val refreshToken = authService.generateRefreshToken(authentication) + val account = authService.authenticate(loginDto.email, loginDto.password) + val accessToken = authService.generateAccessToken(account) + val refreshToken = authService.generateRefreshToken(account) return mapOf("access_token" to accessToken, "refresh_token" to refreshToken) } @PostMapping("/refresh") fun refreshAccessToken(@RequestBody tokenDto: TokenDto): Map { - val accessToken = authService.refreshToken(tokenDto.token) + val accessToken = authService.refreshAccessToken(tokenDto.token) return mapOf("access_token" to accessToken) } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt index 762a3a32..1d01f9a9 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -2,7 +2,6 @@ package pt.up.fe.ni.website.backend.service import org.springframework.http.HttpStatus import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.Authentication import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.context.SecurityContextHolder @@ -27,33 +26,36 @@ class AuthService( val jwtDecoder: JwtDecoder, private val passwordEncoder: PasswordEncoder ) { - fun authenticate(email: String, password: String): Authentication { + fun authenticate(email: String, password: String): Account { val account = accountService.getAccountByEmail(email) if (!passwordEncoder.matches(password, account.password)) { - throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Invalid credentials") + throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "invalid credentials") } - val authorities = listOf("BOARD", "MEMBER").stream() // TODO: get roles from account - .map { role -> SimpleGrantedAuthority(role) } - .collect(Collectors.toList()) - return UsernamePasswordAuthenticationToken(email, password, authorities) + val authentication = UsernamePasswordAuthenticationToken(email, password, getAuthorities(account)) + SecurityContextHolder.getContext().authentication = authentication + return account } - fun generateAccessToken(authentication: Authentication): String { - return generateToken(authentication, Duration.ofMinutes(authConfigProperties.jwtAccessExpirationMinutes)) + fun generateAccessToken(account: Account): String { + return generateToken(account, Duration.ofMinutes(authConfigProperties.jwtAccessExpirationMinutes)) } - fun generateRefreshToken(authentication: Authentication): String { - return generateToken(authentication, Duration.ofDays(authConfigProperties.jwtRefreshExpirationDays), true) + fun generateRefreshToken(account: Account): String { + return generateToken(account, Duration.ofDays(authConfigProperties.jwtRefreshExpirationDays), true) } - fun refreshToken(refreshToken: String): String { - val jwt = jwtDecoder.decode(refreshToken) + fun refreshAccessToken(refreshToken: String): String { + val jwt = + try { + jwtDecoder.decode(refreshToken) + } catch (e: Exception) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid refresh token") + } if (jwt.expiresAt?.isBefore(Instant.now()) != false) { - throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token has expired") + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "refresh token has expired") } val account = accountService.getAccountByEmail(jwt.subject) - val authentication = authenticate(account.email, account.password) - return generateAccessToken(authentication) + return generateAccessToken(account) } fun getAuthenticatedAccount(): Account { @@ -61,8 +63,8 @@ class AuthService( return accountService.getAccountByEmail(authentication.name) } - private fun generateToken(authentication: Authentication, expiration: Duration, isRefresh: Boolean = false): String { - val roles = if (isRefresh) emptyList() else authentication.authorities + private fun generateToken(account: Account, expiration: Duration, isRefresh: Boolean = false): String { + val roles = if (isRefresh) emptyList() else getAuthorities(account) val scope = roles .stream() .map(GrantedAuthority::getAuthority) @@ -73,9 +75,15 @@ class AuthService( .issuer("self") .issuedAt(currentInstant) .expiresAt(currentInstant.plus(expiration)) - .subject(authentication.name) + .subject(account.email) .claim("scope", scope) .build() return jwtEncoder.encode(JwtEncoderParameters.from(claims)).tokenValue } + + private fun getAuthorities(account: Account): List { + return listOf("BOARD", "MEMBER").stream() // TODO: get roles from account + .map { role -> SimpleGrantedAuthority(role) } + .collect(Collectors.toList()) + } } diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt index 853ad019..78850bb9 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt @@ -21,6 +21,7 @@ import pt.up.fe.ni.website.backend.model.Account import pt.up.fe.ni.website.backend.model.CustomWebsite import pt.up.fe.ni.website.backend.repository.AccountRepository import pt.up.fe.ni.website.backend.utils.TestUtils +import pt.up.fe.ni.website.backend.utils.ValidationTester import java.util.Calendar import java.util.Date import pt.up.fe.ni.website.backend.model.constants.AccountConstants as Constants diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt new file mode 100644 index 00000000..1cc3731f --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt @@ -0,0 +1,183 @@ +package pt.up.fe.ni.website.backend.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import pt.up.fe.ni.website.backend.model.Account +import pt.up.fe.ni.website.backend.model.CustomWebsite +import pt.up.fe.ni.website.backend.repository.AccountRepository +import pt.up.fe.ni.website.backend.utils.TestUtils +import java.util.Calendar + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureTestDatabase +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AuthControllerTest @Autowired constructor( + val repository: AccountRepository, + val mockMvc: MockMvc, + val objectMapper: ObjectMapper, + passwordEncoder: PasswordEncoder +) { + final val testPassword = "testPassword" + + // TODO: Make sure to add "MEMBER" role to the account + val testAccount = Account( + "Test Account", + "test_account@test.com", + passwordEncoder.encode(testPassword), + "This is a test account", + TestUtils.createDate(2001, Calendar.JULY, 28), + "https://test-photo.com", + "https://linkedin.com", + "https://github.com", + listOf( + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + ) + ) + + @Nested + @DisplayName("POST /auth/new") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetNewToken { + @BeforeAll + fun setup() { + repository.save(testAccount) + } + + @Test + fun `should fail when email is invalid`() { + mockMvc.post("/auth/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString( + mapOf( + "email" to "president@niaefeup.pt", + "password" to testPassword + ) + ) + }.andExpect { + status { isNotFound() } + jsonPath("$.errors[0].message") { value("account not found with email president@niaefeup.pt") } + } + } + + @Test + fun `should fail when password is incorrect`() { + mockMvc.post("/auth/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(LoginDto(testAccount.email, "wrong_password")) + }.andExpect { + status { isUnprocessableEntity() } + jsonPath("$.errors[0].message") { value("invalid credentials") } + } + } + + @Test + fun `should return access and refresh tokens`() { + mockMvc.post("/auth/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(LoginDto(testAccount.email, testPassword)) + }.andExpect { + status { isOk() } + jsonPath("$.access_token") { exists() } + jsonPath("$.refresh_token") { exists() } + } + } + } + + @Nested + @DisplayName("POST /auth/refresh") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class RefreshToken { + @BeforeAll + fun setup() { + repository.save(testAccount) + } + + @Test + fun `should fail when refresh token is invalid`() { + mockMvc.post("/auth/refresh") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(TokenDto("invalid_refresh_token")) + }.andExpect { + status { isUnauthorized() } + jsonPath("$.errors[0].message") { value("invalid refresh token") } + } + } + + @Test + fun `should return new access token`() { + mockMvc.post("/auth/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(LoginDto(testAccount.email, testPassword)) + }.andReturn().response.let { response -> + val refreshToken = objectMapper.readTree(response.contentAsString)["refresh_token"].asText() + mockMvc.post("/auth/refresh") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(TokenDto(refreshToken)) + }.andExpect { + status { isOk() } + jsonPath("$.access_token") { exists() } + } + } + } + } + + @Nested + @DisplayName("GET /auth/check") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CheckToken { + @BeforeAll + fun setup() { + repository.save(testAccount) + } + + @Test + fun `should fail when no access token is provided`() { + mockMvc.get("/auth").andExpect { + status { isUnauthorized() } + jsonPath("$.errors[0].message") { value("Access is denied") } + } + } + + @Test + fun `should fail when access token is invalid`() { + mockMvc.get("/auth") { + header("Authorization", "Bearer invalid_access_token") + }.andExpect { + status { isUnauthorized() } + } + } + + @Test + fun `should return authenticated user`() { + mockMvc.post("/auth/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(LoginDto(testAccount.email, testPassword)) + }.andReturn().response.let { response -> + val accessToken = objectMapper.readTree(response.contentAsString)["access_token"].asText() + mockMvc.get("/auth") { + header("Authorization", "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.authenticated_user") { value(testAccount.email) } + } + } + } + + // TODO: Add tests for role access when implemented + } +} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/EventControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/EventControllerTest.kt index 6b2784de..eb2f360f 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/EventControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/EventControllerTest.kt @@ -19,6 +19,7 @@ import org.springframework.test.web.servlet.post import pt.up.fe.ni.website.backend.model.Event import pt.up.fe.ni.website.backend.repository.EventRepository import pt.up.fe.ni.website.backend.utils.TestUtils +import pt.up.fe.ni.website.backend.utils.ValidationTester import java.util.Calendar import pt.up.fe.ni.website.backend.model.constants.EventConstants as Constants diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt index 1d28f932..ec137631 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt @@ -1,7 +1,6 @@ package pt.up.fe.ni.website.backend.controller import com.fasterxml.jackson.databind.ObjectMapper -import org.hamcrest.Matchers.not import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.BeforeAll @@ -23,6 +22,7 @@ import org.springframework.test.web.servlet.post import org.springframework.test.web.servlet.put import pt.up.fe.ni.website.backend.model.Post import pt.up.fe.ni.website.backend.repository.PostRepository +import pt.up.fe.ni.website.backend.utils.ValidationTester import java.text.SimpleDateFormat import java.util.Date import pt.up.fe.ni.website.backend.model.constants.PostConstants as Constants diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ProjectControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ProjectControllerTest.kt index af15489b..b89dae7c 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ProjectControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ProjectControllerTest.kt @@ -21,6 +21,7 @@ import org.springframework.test.web.servlet.post import org.springframework.test.web.servlet.put import pt.up.fe.ni.website.backend.model.Project import pt.up.fe.ni.website.backend.repository.ProjectRepository +import pt.up.fe.ni.website.backend.utils.ValidationTester import pt.up.fe.ni.website.backend.model.constants.ProjectConstants as Constants @SpringBootTest diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/ValidationTester.kt similarity index 99% rename from src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt rename to src/test/kotlin/pt/up/fe/ni/website/backend/utils/ValidationTester.kt index 2cfd90c1..7597f483 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/ValidationTester.kt @@ -1,4 +1,4 @@ -package pt.up.fe.ni.website.backend.controller +package pt.up.fe.ni.website.backend.utils import org.springframework.http.MediaType import org.springframework.test.web.servlet.ResultActionsDsl From b897bc078c6b75014e9e890f2973dc3331868874 Mon Sep 17 00:00:00 2001 From: Bruno Mendes Date: Mon, 5 Dec 2022 17:02:57 +0000 Subject: [PATCH 15/15] Do not throw status code exceptions; add semantic names --- .../website/backend/controller/AuthController.kt | 3 +-- .../website/backend/controller/ErrorController.kt | 8 -------- .../fe/ni/website/backend/service/AuthService.kt | 15 +++++++-------- .../backend/controller/AccountControllerTest.kt | 14 ++++---------- .../backend/controller/AuthControllerTest.kt | 6 ++---- 5 files changed, 14 insertions(+), 32 deletions(-) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index 0ff40c20..41b7e46b 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -7,7 +7,6 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import pt.up.fe.ni.website.backend.service.AuthService -import javax.servlet.http.HttpServletRequest data class LoginDto( val email: String, @@ -37,7 +36,7 @@ class AuthController(val authService: AuthService) { @GetMapping @PreAuthorize("hasRole('MEMBER')") - fun checkAuthentication(request: HttpServletRequest): Map { + fun checkAuthentication(): Map { val account = authService.getAuthenticatedAccount() return mapOf("authenticated_user" to account.email) } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt index bd4d83e6..b78362f0 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt @@ -13,8 +13,6 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestControllerAdvice -import org.springframework.web.server.ResponseStatusException -import javax.servlet.http.HttpServletResponse import javax.validation.ConstraintViolationException data class SimpleError( @@ -110,12 +108,6 @@ class ErrorController : ErrorController { return wrapSimpleError(e.message ?: "invalid authentication") } - @ExceptionHandler(ResponseStatusException::class) - fun expectedError(e: ResponseStatusException, response: HttpServletResponse): CustomError { - response.status = e.status.value() - return wrapSimpleError(e.reason ?: (e.message)) - } - fun wrapSimpleError(msg: String, param: String? = null, value: Any? = null) = CustomError( mutableListOf(SimpleError(msg, param, value)) ) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt index 1d01f9a9..4f5435b1 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -1,6 +1,5 @@ package pt.up.fe.ni.website.backend.service -import org.springframework.http.HttpStatus import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority @@ -10,8 +9,8 @@ import org.springframework.security.oauth2.jwt.JwtClaimsSet import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.JwtEncoderParameters +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException import org.springframework.stereotype.Service -import org.springframework.web.server.ResponseStatusException import pt.up.fe.ni.website.backend.config.auth.AuthConfigProperties import pt.up.fe.ni.website.backend.model.Account import java.time.Duration @@ -29,9 +28,9 @@ class AuthService( fun authenticate(email: String, password: String): Account { val account = accountService.getAccountByEmail(email) if (!passwordEncoder.matches(password, account.password)) { - throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "invalid credentials") + throw InvalidBearerTokenException("invalid credentials") } - val authentication = UsernamePasswordAuthenticationToken(email, password, getAuthorities(account)) + val authentication = UsernamePasswordAuthenticationToken(email, password, getAuthorities()) SecurityContextHolder.getContext().authentication = authentication return account } @@ -49,10 +48,10 @@ class AuthService( try { jwtDecoder.decode(refreshToken) } catch (e: Exception) { - throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid refresh token") + throw InvalidBearerTokenException("invalid refresh token") } if (jwt.expiresAt?.isBefore(Instant.now()) != false) { - throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "refresh token has expired") + throw InvalidBearerTokenException("refresh token has expired") } val account = accountService.getAccountByEmail(jwt.subject) return generateAccessToken(account) @@ -64,7 +63,7 @@ class AuthService( } private fun generateToken(account: Account, expiration: Duration, isRefresh: Boolean = false): String { - val roles = if (isRefresh) emptyList() else getAuthorities(account) + val roles = if (isRefresh) emptyList() else getAuthorities() // TODO: Pass account to getAuthorities() val scope = roles .stream() .map(GrantedAuthority::getAuthority) @@ -81,7 +80,7 @@ class AuthService( return jwtEncoder.encode(JwtEncoderParameters.from(claims)).tokenValue } - private fun getAuthorities(account: Account): List { + private fun getAuthorities(): List { return listOf("BOARD", "MEMBER").stream() // TODO: get roles from account .map { role -> SimpleGrantedAuthority(role) } .collect(Collectors.toList()) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt index 78850bb9..65783fe6 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt @@ -401,16 +401,10 @@ class AccountControllerTest @Autowired constructor( // password is ignored on serialization, so add it manually // for account creation test cases return objectMapper.writeValueAsString( - mapOf( - "name" to this?.name, - "email" to this?.email, - "password" to this?.password, - "bio" to this?.bio, - "birthDate" to this?.birthDate.toJson(), - "photoPath" to this?.photoPath, - "linkedin" to this?.linkedin, - "github" to this?.github, - "websites" to this?.websites + objectMapper.convertValue(this, Map::class.java).plus( + mapOf( + "password" to this?.password + ) ) ) } diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt index 89031dca..f22725cc 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt @@ -60,7 +60,7 @@ class AuthControllerTest @Autowired constructor( } @Test - fun `should fail when email is invalid`() { + fun `should fail when email is not registered`() { mockMvc.post("/auth/new") { contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString( @@ -81,7 +81,7 @@ class AuthControllerTest @Autowired constructor( contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(LoginDto(testAccount.email, "wrong_password")) }.andExpect { - status { isUnprocessableEntity() } + status { isUnauthorized() } jsonPath("$.errors[0].message") { value("invalid credentials") } } } @@ -179,7 +179,5 @@ class AuthControllerTest @Autowired constructor( } } } - - // TODO: Add tests for role access when implemented } }