Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/auth #54

Merged
merged 18 commits into from
Dec 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
class AuthConfig(
val authConfigProperties: AuthConfigProperties,
@Qualifier("handlerExceptionResolver") val exceptionResolver: HandlerExceptionResolver
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http.csrf { csrf -> csrf.disable() }.cors().and()
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
.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()
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
}

@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_")
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter)
return converter
}
}
Original file line number Diff line number Diff line change
@@ -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
)
bdmendes marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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
)
Comment on lines +11 to +18
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should think about how we want to store these DTOs. Until now, we only used DTOs that directly match our models so they're in the model/ package. However, this is not the case here.

I don't have a solution right now so I'll leave some food for thought. Should we keep all DTOs/DAOs in their own package and rename our current Dto abstract class to ModelDto or something of sorts? Or should we keep our current implementation and just move these new type of Dtos to their own package?

Please let em know what you think

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do like this:

dto/
     model/
     auth/
     otherSemanticUseCase/

Should I do this here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that as well. I think we should do it here, so we don't merge code that's going to change immediately


@RestController
@RequestMapping("/auth")
class AuthController(val authService: AuthService) {
@PostMapping("/new")
fun getNewToken(@RequestBody loginDto: LoginDto): Map<String, String> {
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<String, String> {
val accessToken = authService.refreshAccessToken(tokenDto.token)
return mapOf("access_token" to accessToken)
}

@GetMapping
@PreAuthorize("hasRole('MEMBER')")
fun checkAuthentication(): Map<String, String> {
val account = authService.getAuthenticatedAccount()
return mapOf("authenticated_user" to account.email)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
}

fun wrapSimpleError(msg: String, param: String? = null, value: Any? = null) = CustomError(
Expand Down
5 changes: 4 additions & 1 deletion src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand All @@ -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?,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@ object AccountConstants {
const val minSize = 5
const val maxSize = 500
}

object Password {
const val minSize = 8
const val maxSize = 100
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CustomWebsiteDto>
val websites: List<CustomWebsiteDto>?
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
) : Dto<Account>()
Original file line number Diff line number Diff line change
@@ -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<Account> = repository.findAll().toList()

fun createAccount(dto: AccountDto): Account {
Expand All @@ -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")
}
88 changes: 88 additions & 0 deletions src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt
Original file line number Diff line number Diff line change
@@ -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)
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
.build()
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).tokenValue
}

private fun getAuthorities(): List<GrantedAuthority> {
return listOf("BOARD", "MEMBER").stream() // TODO: get roles from account
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
.map { role -> SimpleGrantedAuthority(role) }
.collect(Collectors.toList())
}
}
6 changes: 6 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading