diff --git a/build.gradle.kts b/build.gradle.kts index f23722fd..bc1167ae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,7 +23,9 @@ 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-validation:2.7.3") + 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.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/BackendApplication.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt index f4ee374a..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 @@ -1,10 +1,13 @@ 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 org.springframework.data.jpa.repository.config.EnableJpaAuditing +import pt.up.fe.ni.website.backend.config.auth.AuthConfigProperties @SpringBootApplication +@EnableConfigurationProperties(AuthConfigProperties::class) @EnableJpaAuditing class BackendApplication 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 new file mode 100644 index 00000000..747fa94b --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt @@ -0,0 +1,82 @@ +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.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +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.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 +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 +import org.springframework.web.filter.CorsFilter +import org.springframework.web.servlet.HandlerExceptionResolver + +@Configuration +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) +class AuthConfig( + val authConfigProperties: AuthConfigProperties, + @Qualifier("handlerExceptionResolver") val exceptionResolver: HandlerExceptionResolver +) { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + return http.csrf { csrf -> csrf.disable() }.cors().and() + .oauth2ResourceServer().jwt() + .jwtAuthenticationConverter(rolesConverter()) + .and().authenticationEntryPoint { request, response, exception -> + exceptionResolver.resolveException(request, response, null, exception) + }.and() + .sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .httpBasic().disable().build() + } + + @Bean + fun jwtDecoder(): JwtDecoder { + return NimbusJwtDecoder.withPublicKey(authConfigProperties::publicKey.get()).build() + } + + @Bean + fun jwtEncoder(): JwtEncoder { + val jwt = RSAKey.Builder(authConfigProperties::publicKey.get()).privateKey(authConfigProperties::privateKey.get()).build() + return NimbusJwtEncoder(ImmutableJWKSet(JWKSet(jwt))) + } + + @Bean + 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) + } + + 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/config/auth/AuthConfigProperties.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfigProperties.kt new file mode 100644 index 00000000..a03014ff --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfigProperties.kt @@ -0,0 +1,15 @@ +package pt.up.fe.ni.website.backend.config.auth + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey + +@ConstructorBinding +@ConfigurationProperties(prefix = "auth") +data class AuthConfigProperties( + val publicKey: RSAPublicKey, + 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 new file mode 100644 index 00000000..41b7e46b --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -0,0 +1,43 @@ +package pt.up.fe.ni.website.backend.controller + +import org.springframework.security.access.prepost.PreAuthorize +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 + +data class LoginDto( + val email: String, + val password: String +) + +data class TokenDto( + val token: String +) + +@RestController +@RequestMapping("/auth") +class AuthController(val authService: AuthService) { + @PostMapping("/new") + fun getNewToken(@RequestBody loginDto: LoginDto): Map { + 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.refreshAccessToken(tokenDto.token) + return mapOf("access_token" to accessToken) + } + + @GetMapping + @PreAuthorize("hasRole('MEMBER')") + 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 1a6c7903..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 @@ -6,6 +6,8 @@ import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException import org.springframework.boot.web.servlet.error.ErrorController import org.springframework.http.HttpStatus import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.core.AuthenticationException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus @@ -91,7 +93,19 @@ class ErrorController : ErrorController { @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) fun unexpectedError(e: Exception): CustomError { System.err.println(e) - return wrapSimpleError("unexpected error") + return wrapSimpleError("unexpected error: " + e.message) + } + + @ExceptionHandler(AccessDeniedException::class) + @ResponseStatus(HttpStatus.FORBIDDEN) + fun forbidden(e: AccessDeniedException): CustomError { + return wrapSimpleError(e.message ?: "you don't have permission to access this resource") + } + + @ExceptionHandler(AuthenticationException::class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + fun invalidAuthentication(e: AuthenticationException): CustomError { + return wrapSimpleError(e.message ?: "invalid authentication") } fun wrapSimpleError(msg: String, param: String? = null, value: Any? = null) = CustomError( 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..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 @@ -21,7 +21,6 @@ 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, @@ -31,6 +30,10 @@ class Account( @field:Email var email: String, + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY, required = true) + @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..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 @@ -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 new file mode 100644 index 00000000..4f5435b1 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -0,0 +1,88 @@ +package pt.up.fe.ni.website.backend.service + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +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.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 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.util.stream.Collectors + +@Service +class AuthService( + val accountService: AccountService, + val authConfigProperties: AuthConfigProperties, + val jwtEncoder: JwtEncoder, + val jwtDecoder: JwtDecoder, + private val passwordEncoder: PasswordEncoder +) { + fun authenticate(email: String, password: String): Account { + val account = accountService.getAccountByEmail(email) + if (!passwordEncoder.matches(password, account.password)) { + throw InvalidBearerTokenException("invalid credentials") + } + val authentication = UsernamePasswordAuthenticationToken(email, password, getAuthorities()) + SecurityContextHolder.getContext().authentication = authentication + return account + } + + fun generateAccessToken(account: Account): String { + return generateToken(account, Duration.ofMinutes(authConfigProperties.jwtAccessExpirationMinutes)) + } + + fun generateRefreshToken(account: Account): String { + return generateToken(account, Duration.ofDays(authConfigProperties.jwtRefreshExpirationDays), true) + } + + fun refreshAccessToken(refreshToken: String): String { + val jwt = + try { + jwtDecoder.decode(refreshToken) + } catch (e: Exception) { + throw InvalidBearerTokenException("invalid refresh token") + } + if (jwt.expiresAt?.isBefore(Instant.now()) != false) { + throw InvalidBearerTokenException("refresh token has expired") + } + val account = accountService.getAccountByEmail(jwt.subject) + return generateAccessToken(account) + } + + fun getAuthenticatedAccount(): Account { + val authentication = SecurityContextHolder.getContext().authentication + return accountService.getAccountByEmail(authentication.name) + } + + private fun generateToken(account: Account, expiration: Duration, isRefresh: Boolean = false): String { + val roles = if (isRefresh) emptyList() else getAuthorities() // TODO: Pass account to getAuthorities() + val scope = roles + .stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(" ")) + val currentInstant = Instant.now() + val claims = JwtClaimsSet + .builder() + .issuer("self") + .issuedAt(currentInstant) + .expiresAt(currentInstant.plus(expiration)) + .subject(account.email) + .claim("scope", scope) + .build() + return jwtEncoder.encode(JwtEncoderParameters.from(claims)).tokenValue + } + + 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/main/resources/application.properties b/src/main/resources/application.properties index 656e870c..57d62dff 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -18,3 +18,9 @@ server.error.whitelabel.enabled=false 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=60 +auth.jwt-refresh-expiration-days=7 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----- 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..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 @@ -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 @@ -37,6 +38,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 +58,7 @@ class AccountControllerTest @Autowired constructor( Account( "Test Account 2", "test_account2@test.com", + "test_password", null, null, null, @@ -132,7 +135,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) } @@ -162,6 +165,7 @@ class AccountControllerTest @Autowired constructor( requiredFields = mapOf( "name" to testAccount.name, "email" to testAccount.email, + "password" to testAccount.password, "websites" to emptyList() ) ) @@ -202,6 +206,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) @@ -292,6 +313,7 @@ class AccountControllerTest @Autowired constructor( mapOf( "name" to testAccount.name, "email" to testAccount.email, + "password" to testAccount.password, "websites" to listOf(params) ) ) @@ -351,14 +373,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) } @@ -373,4 +396,16 @@ 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( + 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 new file mode 100644 index 00000000..f22725cc --- /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.hamcrest.Matchers.startsWith +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 not registered`() { + 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 { isUnauthorized() } + 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 { isForbidden() } + 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() } + jsonPath("$.errors[0].message") { startsWith("An error occurred while attempting to decode the Jwt") } + } + } + + @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) } + } + } + } + } +} 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 169bef51..9286cee9 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.ActivityConstants 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 14db7bf3..ddb876a8 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.ActivityConstants 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