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 17 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)
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
}
}
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
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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
import javax.servlet.http.HttpServletRequest

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(request: HttpServletRequest): 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,11 +6,15 @@ 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
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(
Expand Down Expand Up @@ -91,7 +95,25 @@ 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")
}

@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(
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>?
) : 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")
}
89 changes: 89 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,89 @@
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
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.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 ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "invalid credentials")
}
val authentication = UsernamePasswordAuthenticationToken(email, password, getAuthorities(account))
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 ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid refresh token")
}
if (jwt.expiresAt?.isBefore(Instant.now()) != false) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "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(account)
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(account: Account): List<GrantedAuthority> {
return listOf("BOARD", "MEMBER").stream() // TODO: get roles from account
.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