diff --git a/authentication/pom.xml b/authentication/pom.xml new file mode 100644 index 0000000..829bbec --- /dev/null +++ b/authentication/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + com.coded.spring + Kotlin.SpringbootV2 + 0.0.1-SNAPSHOT + + + authentication + + + UTF-8 + official + 1.8 + + + \ No newline at end of file diff --git a/src/main/kotlin/com/coded/spring/ordering/Application.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/AuthenticationApplication.kt similarity index 57% rename from src/main/kotlin/com/coded/spring/ordering/Application.kt rename to authentication/src/main/kotlin/com/coded/spring/authentication/AuthenticationApplication.kt index 8554e49..97e486b 100644 --- a/src/main/kotlin/com/coded/spring/ordering/Application.kt +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/AuthenticationApplication.kt @@ -1,11 +1,12 @@ -package com.coded.spring.ordering +package com.coded.spring.authentication + import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication -class Application +class AuthenticationApplication fun main(args: Array) { - runApplication(*args) -} + runApplication(*args) +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/AuthenticationController.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/AuthenticationController.kt new file mode 100644 index 0000000..50a4c5b --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/AuthenticationController.kt @@ -0,0 +1,76 @@ +package com.coded.spring.authentication + +import com.coded.spring.authentication.jwt.JwtService +import com.coded.spring.authentication.users.UserService +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +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 java.security.Principal + + +@Tag(name="AuthenticationAPI") +@RestController +@RequestMapping("/authentication") +class AuthenticationController( + private val authenticationManager: AuthenticationManager, + private val userDetailsService: UserDetailsService, + private val jwtService: JwtService, + private val userService: UserService +) { + @Tag(name = "AuthenticationAPI") + @PostMapping("/login") + fun login(@RequestBody authRequest: AuthenticationRequest): AuthenticationResponse { + val authToken = UsernamePasswordAuthenticationToken(authRequest.username, authRequest.password) + val authentication = authenticationManager.authenticate(authToken) + + if (authentication.isAuthenticated) { + val userDetails = userDetailsService.loadUserByUsername(authRequest.username) + val token = jwtService.generateToken(userDetails.username) + return AuthenticationResponse (token) + } else { + throw UsernameNotFoundException("Invalid user request!") + } + } + + @PostMapping("/check-token") + fun checkToken( + principal: Principal + ): CheckTokenResponse { + return CheckTokenResponse( + userId = userService.findByUsername(principal.name) + ) + } +} + + +data class AuthenticationRequest( + val username: String, + val password: String +) + +data class AuthenticationResponse( + val token: String +) + +data class CheckTokenResponse( + val userId: Long +) + +data class RegisterRequest( + val username: String, + val password: String +) + +data class RegisterFailureResponse( + val error: AddUserError +) + +enum class AddUserError { + INVALID_USERNAME, TOO_SHORT_PASSWORD, MAX_ACCOUNT_LIMIT_REACHED +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/CustomUserDetailsService.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/CustomUserDetailsService.kt new file mode 100644 index 0000000..97d1061 --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/CustomUserDetailsService.kt @@ -0,0 +1,25 @@ +package com.coded.spring.authentication + + +import com.coded.spring.authentication.users.UserEntity +import com.coded.spring.authentication.users.UserRepository +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.stereotype.Service + +@Service +class CustomUserDetailsService( + private val userRepository: UserRepository +) : UserDetailsService { + override fun loadUserByUsername(username: String): UserDetails { + val user : UserEntity = userRepository.findByUsername(username) ?: + throw UsernameNotFoundException("User not found...") + + return User.builder() + .username(user.username) + .password(user.password) + .build() + } +} diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/LoggingFilter.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/LoggingFilter.kt new file mode 100644 index 0000000..9064f5d --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/LoggingFilter.kt @@ -0,0 +1,92 @@ +package com.coded.spring.authentication + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.util.ContentCachingRequestWrapper +import org.springframework.web.util.ContentCachingResponseWrapper + +@Component +class LoggingFilter : OncePerRequestFilter() { + + private val logger = LoggerFactory.getLogger(LoggingFilter::class.java) + private val objectMapper = ObjectMapper().apply { + enable(SerializationFeature.INDENT_OUTPUT) + } + + private val RESET = "\u001B[0m" + private val GREEN = "\u001B[32m" + private val YELLOW = "\u001B[33m" + private val RED = "\u001B[31m" + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val cachedRequest = ContentCachingRequestWrapper(request) + val cachedResponse = ContentCachingResponseWrapper(response) + + filterChain.doFilter(cachedRequest, cachedResponse) + + logRequest(cachedRequest) + logResponse(cachedResponse) + + cachedResponse.copyBodyToResponse() + } + + private fun logRequest(request: ContentCachingRequestWrapper) { + val requestBody = request.contentAsByteArray.toString(Charsets.UTF_8).trim() + + logger.info( + """ + |[*] Incoming Request + |Method: ${request.method} + |URI: ${request.requestURI} + |Body: ${formatJsonIfPossible(requestBody)} + |------------------------------------------------------------------------------------------------ + """.trimMargin() + ) + } + + private fun logResponse(response: ContentCachingResponseWrapper) { + val responseBody = response.contentAsByteArray.toString(Charsets.UTF_8).trim() + val color = getColorForStatus(response.status) + + logger.info( + """ + |[*] Outgoing Response + |Status: $color${response.status}$RESET + |Body: ${formatJsonIfPossible(responseBody)} + |=============================================================================================== + """.trimMargin() + ) + } + + private fun formatJsonIfPossible(content: String): String { + return try { + if (content.isBlank()) { + "(empty body)" + } else { + val jsonNode = objectMapper.readTree(content) + objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode) + } + } catch (ex: Exception) { + content + } + } + + private fun getColorForStatus(status: Int): String { + return when { + status in 200..299 -> GREEN + status in 400..499 -> YELLOW + status >= 500 -> RED + else -> RESET + } + } +} diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/Mohammed-Sheshtar-Authentication-api-swagger-01.json b/authentication/src/main/kotlin/com/coded/spring/authentication/Mohammed-Sheshtar-Authentication-api-swagger-01.json new file mode 100644 index 0000000..942c33a --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/Mohammed-Sheshtar-Authentication-api-swagger-01.json @@ -0,0 +1 @@ +{"openapi":"3.0.1","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:9002","description":"Generated server url"}],"paths":{"/register":{"post":{"tags":["UserAPI"],"operationId":"registerUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/profile":{"post":{"tags":["ProfileAPI"],"operationId":"addProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestProfileDTO"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/authentication/login":{"post":{"tags":["AuthenticationAPI"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthenticationRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthenticationResponse"}}}}}}},"/authentication/check-token":{"post":{"tags":["AuthenticationAPI"],"operationId":"checkToken","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CheckTokenResponse"}}}}}}}},"components":{"schemas":{"CreateUserRequest":{"required":["password","username"],"type":"object","properties":{"username":{"type":"string"},"password":{"type":"string"}}},"RequestProfileDTO":{"required":["firstName","lastName","phoneNumber"],"type":"object","properties":{"firstName":{"type":"string"},"lastName":{"type":"string"},"phoneNumber":{"type":"string"}}},"AuthenticationRequest":{"required":["password","username"],"type":"object","properties":{"username":{"type":"string"},"password":{"type":"string"}}},"AuthenticationResponse":{"required":["token"],"type":"object","properties":{"token":{"type":"string"}}},"CheckTokenResponse":{"required":["userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int64"}}}}}} \ No newline at end of file diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/SecurityConfig.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/SecurityConfig.kt new file mode 100644 index 0000000..efbb924 --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/SecurityConfig.kt @@ -0,0 +1,61 @@ +package com.coded.spring.authentication + +import com.coded.spring.authentication.jwt.JwtAuthenticationFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.authentication.dao.DaoAuthenticationProvider +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration +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.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + + + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val userDetailsService: CustomUserDetailsService, + private val jwtAuthFilter: JwtAuthenticationFilter, +) { + + @Bean + fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http.csrf { it.disable() } + .authorizeHttpRequests { + it.requestMatchers( + "/register", + "/authentication/**", + "api-docs", + "/hello").permitAll() // public route + .anyRequest().authenticated() + } + .sessionManagement { + it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java) + + return http.build() + } + + @Bean + fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager = + config.authenticationManager + + @Bean + fun authenticationProvider(): AuthenticationProvider { + val provider = DaoAuthenticationProvider() + provider.setUserDetailsService(userDetailsService) + provider.setPasswordEncoder(passwordEncoder()) + return provider + } +} diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/jwt/JwtAuthenticationFilter.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/jwt/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..10e75b5 --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/jwt/JwtAuthenticationFilter.kt @@ -0,0 +1,47 @@ +package com.coded.spring.authentication.jwt + + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class JwtAuthenticationFilter( + private val jwtService: JwtService, + private val userDetailsService: UserDetailsService +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val authHeader = request.getHeader("Authorization") + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response) + return + } + + val token = authHeader.substring(7) + val username = jwtService.extractUsername(token) + + if (SecurityContextHolder.getContext().authentication == null) { + if (jwtService.isTokenValid(token, username)) { + val userDetails = userDetailsService.loadUserByUsername(username) + val authToken = UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.authorities + ) + authToken.details = WebAuthenticationDetailsSource().buildDetails(request) + SecurityContextHolder.getContext().authentication = authToken + } + } + + filterChain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/jwt/JwtService.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/jwt/JwtService.kt new file mode 100644 index 0000000..b43d384 --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/jwt/JwtService.kt @@ -0,0 +1,43 @@ +package com.coded.spring.authentication.jwt + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.security.Keys +import org.springframework.stereotype.Component +import java.util.* +import javax.crypto.SecretKey + +@Component +class JwtService { + + private val secretKey: SecretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256) + private val expirationMs: Long = 1000 * 60 * 60 + + fun generateToken(username: String): String { + val now = Date() + val expiry = Date(now.time + expirationMs) + + return Jwts.builder() + .setSubject(username) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(secretKey) + .compact() + } + + fun extractUsername(token: String): String = + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .body + .subject + + fun isTokenValid(token: String, username: String): Boolean { + return try { + extractUsername(token) == username + } catch (e: Exception) { + false + } + } +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/profiles/ProfileController.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/profiles/ProfileController.kt new file mode 100644 index 0000000..31c1404 --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/profiles/ProfileController.kt @@ -0,0 +1,27 @@ +package com.coded.spring.authentication.profiles + + +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.core.userdetails.User + +@Tag(name="ProfileAPI") +@RestController +class ProfileController( + private val profileService: ProfileService +) { + @PostMapping("/profile") + fun addProfile( @AuthenticationPrincipal user: User, @RequestBody request: RequestProfileDTO): ResponseEntity { + return profileService.createProfile(username = user.username, request = request) + } +} + +data class RequestProfileDTO( + val firstName: String, + val lastName: String, + val phoneNumber: String +) + + diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/profiles/ProfileRepository.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/profiles/ProfileRepository.kt new file mode 100644 index 0000000..5b9c2b2 --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/profiles/ProfileRepository.kt @@ -0,0 +1,35 @@ +package com.coded.spring.authentication.profiles + + +import jakarta.inject.Named +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository +import com.coded.spring.authentication.users.UserEntity + +@Named +interface ProfileRepository : JpaRepository { + fun findByUserId(userId: Long): ProfileEntity? +} + +@Entity +@Table(name = "profiles") +data class ProfileEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @OneToOne + @JoinColumn(name = "user_id") + val user: UserEntity, + + @Column(name = "first_name") + val firstName: String, + + @Column(name = "last_name") + val lastName: String, + + @Column(name = "phone_number") + val phoneNumber: String +) { + constructor() : this(null, UserEntity(), "", "", "") +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/profiles/ProfileService.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/profiles/ProfileService.kt new file mode 100644 index 0000000..92bb99a --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/profiles/ProfileService.kt @@ -0,0 +1,48 @@ +package com.coded.spring.authentication.profiles + +import com.coded.spring.authentication.users.UserRepository +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service + +@Service +class ProfileService( + private val profileRepository: ProfileRepository, + private val userRepository: UserRepository +) { + fun createProfile(username: String, request: RequestProfileDTO): ResponseEntity { + val user = userRepository.findByUsername(username) + ?: return ResponseEntity.badRequest().body(mapOf("error" to "username was not found")) + + if(request.firstName.any { it.isDigit() }) { + return ResponseEntity.badRequest().body(mapOf("error" to "first name must not contain any numbers")) + } + + if(request.lastName. any { it.isDigit() }) { + return ResponseEntity.badRequest().body(mapOf("error" to "last name must not contain any numbers")) + } + + if(!request.phoneNumber.matches(Regex("^\\d{8}$"))) { + return ResponseEntity.badRequest().body(mapOf("error" to "phone number must be 8 digits")) + } + + val existingProfile = profileRepository.findByUserId(user.id!!) + + val profile = if (existingProfile != null) { + existingProfile.copy( + firstName = request.firstName, + lastName = request.lastName, + phoneNumber = request.phoneNumber + ) + } else { + ProfileEntity( + user = user, + firstName = request.firstName, + lastName = request.lastName, + phoneNumber = request.phoneNumber + ) + } + + profileRepository.save(profile) + return ResponseEntity.ok().build() + } +} diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/scripts/InitUserRunner.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/scripts/InitUserRunner.kt new file mode 100644 index 0000000..a1d7d64 --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/scripts/InitUserRunner.kt @@ -0,0 +1,34 @@ +package com.coded.spring.authentication.scripts + + +import com.coded.spring.authentication.AuthenticationApplication +import com.coded.spring.authentication.users.UserEntity +import com.coded.spring.authentication.users.UserRepository +import org.springframework.boot.CommandLineRunner +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import com.coded.spring.authentication.users.Roles +import org.springframework.context.annotation.Bean +import org.springframework.security.crypto.password.PasswordEncoder + +@SpringBootApplication +class InitUserRunner { + @Bean + fun initUsers(userRepository: UserRepository, passwordEncoder: PasswordEncoder) = CommandLineRunner { + val user = UserEntity( + username = "momo1111112", + password = passwordEncoder.encode("password123"), + role = Roles.USER + ) + if (userRepository.findByUsername(user.username) == null) { + println("Creating user ${user.username}") + userRepository.save(user) + } else { + println("User ${user.username} already exists") + } + } +} + +fun main(args: Array) { + runApplication(*args).close() +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/users/UserController.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/users/UserController.kt new file mode 100644 index 0000000..98ebe8c --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/users/UserController.kt @@ -0,0 +1,30 @@ +package com.coded.spring.authentication.users + + +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.constraints.NotBlank +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + + +@Tag(name="UserAPI") +@RestController +class UserController( + private val userService : UserService +) { + @PostMapping("register") + fun registerUser(@RequestBody request: CreateUserRequest): ResponseEntity { + return userService.registerUser(request) + } +} + +data class CreateUserRequest( + @field:NotBlank(message = "Username is required") + val username: String, + + @field:NotBlank(message = "Password is required") + val password: String +) + diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/users/UserRepository.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/users/UserRepository.kt new file mode 100644 index 0000000..a3e2852 --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/users/UserRepository.kt @@ -0,0 +1,31 @@ +package com.coded.spring.authentication.users + +import jakarta.inject.Named +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository + +@Named +interface UserRepository : JpaRepository{ + fun findByUsername(userName: String): UserEntity? + fun existsByUsername(username: String): Boolean +} + +@Entity +@Table(name = "users") +data class UserEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + val username: String, + val password: String, + + @Enumerated(EnumType.STRING) + val role: Roles = Roles.USER +){ + constructor() : this(null, "", "") +} + +enum class Roles { + USER, ADMIN +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/com/coded/spring/authentication/users/UserService.kt b/authentication/src/main/kotlin/com/coded/spring/authentication/users/UserService.kt new file mode 100644 index 0000000..a2369e2 --- /dev/null +++ b/authentication/src/main/kotlin/com/coded/spring/authentication/users/UserService.kt @@ -0,0 +1,45 @@ +package com.coded.spring.authentication.users + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service + + @Service + class UserService( + private val userRepository: UserRepository, + private val passwordEncoder: PasswordEncoder + ) { + + + fun findByUsername (username: String): Long + = userRepository.findByUsername(username)?.id ?: throw IllegalStateException("User has no id...") + + fun registerUser(request: CreateUserRequest): ResponseEntity { + if (userRepository.existsByUsername(request.username)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(mapOf("error" to "username ${request.username} already exists")) + } + + if (request.password.length < 6) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(mapOf("error" to "password must be at least 6 characters")) + } + + if (!request.password.any { it.isUpperCase() }) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(mapOf("error" to "password must have at least one capital letter")) + } + + if (!request.password.any { it.isDigit() }) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(mapOf("error" to "password must have at least one digit")) + } + + val hashedPassword = passwordEncoder.encode(request.password) + val newUser = UserEntity(username = request.username, password = hashedPassword) + userRepository.save(newUser) + + return ResponseEntity.ok().build() + } + } diff --git a/authentication/src/main/resources/application.properties b/authentication/src/main/resources/application.properties new file mode 100644 index 0000000..179eb66 --- /dev/null +++ b/authentication/src/main/resources/application.properties @@ -0,0 +1,8 @@ +spring.application.name=Kotlin.SpringbootV2 +server.port=9002 +spring.datasource.url=jdbc:postgresql://localhost:5432/OnlineOrderingDatabase +spring.datasource.username=postgres +spring.datasource.password=123 +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +springdoc.api-docs.path=/api-docs \ No newline at end of file diff --git a/ordering/pom.xml b/ordering/pom.xml new file mode 100644 index 0000000..ba0ba2d --- /dev/null +++ b/ordering/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + com.coded.spring + Kotlin.SpringbootV2 + 0.0.1-SNAPSHOT + + + ordering + + + UTF-8 + official + 1.8 + + + + com.coded.spring + authentication + 0.0.1-SNAPSHOT + compile + + + \ No newline at end of file diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/HelloWorldController.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/HelloWorldController.kt new file mode 100644 index 0000000..746a783 --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/HelloWorldController.kt @@ -0,0 +1,16 @@ +package com.coded.spring.ordering + +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.beans.factory.annotation.Value +import org.springframework.web.bind.annotation.* + +@Tag(name="HelloWorldAPI") +@RestController +class HelloWorldController( + @Value("\${hello-world}") + val helloWorldMessage: String +) { + + @GetMapping("/hello") + fun helloWorld() = helloWorldMessage; +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/LoggingFilter.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/LoggingFilter.kt new file mode 100644 index 0000000..e7e5440 --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/LoggingFilter.kt @@ -0,0 +1,92 @@ +package com.coded.spring.ordering + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.util.ContentCachingRequestWrapper +import org.springframework.web.util.ContentCachingResponseWrapper +import org.slf4j.LoggerFactory + +@Component +class LoggingFilter : OncePerRequestFilter() { + + private val logger = LoggerFactory.getLogger(LoggingFilter::class.java) + private val objectMapper = ObjectMapper().apply { + enable(SerializationFeature.INDENT_OUTPUT) + } + + private val RESET = "\u001B[0m" + private val GREEN = "\u001B[32m" + private val YELLOW = "\u001B[33m" + private val RED = "\u001B[31m" + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val cachedRequest = ContentCachingRequestWrapper(request) + val cachedResponse = ContentCachingResponseWrapper(response) + + filterChain.doFilter(cachedRequest, cachedResponse) + + logRequest(cachedRequest) + logResponse(cachedResponse) + + cachedResponse.copyBodyToResponse() + } + + private fun logRequest(request: ContentCachingRequestWrapper) { + val requestBody = request.contentAsByteArray.toString(Charsets.UTF_8).trim() + + logger.info( + """ + |[*] Incoming Request + |Method: ${request.method} + |URI: ${request.requestURI} + |Body: ${formatJsonIfPossible(requestBody)} + |------------------------------------------------------------------------------------------------ + """.trimMargin() + ) + } + + private fun logResponse(response: ContentCachingResponseWrapper) { + val responseBody = response.contentAsByteArray.toString(Charsets.UTF_8).trim() + val color = getColorForStatus(response.status) + + logger.info( + """ + |[*] Outgoing Response + |Status: $color${response.status}$RESET + |Body: ${formatJsonIfPossible(responseBody)} + |=============================================================================================== + """.trimMargin() + ) + } + + private fun formatJsonIfPossible(content: String): String { + return try { + if (content.isBlank()) { + "(empty body)" + } else { + val jsonNode = objectMapper.readTree(content) + objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode) + } + } catch (ex: Exception) { + content + } + } + + private fun getColorForStatus(status: Int): String { + return when { + status in 200..299 -> GREEN + status in 400..499 -> YELLOW + status >= 500 -> RED + else -> RESET + } + } +} diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/Mohammed-Sheshtar-online-ordering-api-swagger-03.json b/ordering/src/main/kotlin/com/coded/spring/ordering/Mohammed-Sheshtar-online-ordering-api-swagger-03.json new file mode 100644 index 0000000..172e5ad --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/Mohammed-Sheshtar-online-ordering-api-swagger-03.json @@ -0,0 +1 @@ +{"openapi":"3.0.1","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:9001","description":"Generated server url"}],"paths":{"/orders/add":{"post":{"tags":["OrderAPI"],"operationId":"addOrders","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestOrder"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/menus":{"get":{"tags":["MenuAPI"],"operationId":"listMenu","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MenuEntity"}}}}}}},"post":{"tags":["MenuAPI"],"operationId":"createMenu","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MenuDTO"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/MenuDTO"}}}}}}},"/welcome":{"get":{"tags":["WelcomeAPI"],"operationId":"greetUser","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"string"}}}}}}},"/orders":{"get":{"tags":["OrderAPI"],"operationId":"getOrders","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/OrderResponseDTO"}}}}}}}},"/hello":{"get":{"tags":["HelloWorldAPI"],"operationId":"helloWorld","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"string"}}}}}}}},"components":{"schemas":{"RequestItem":{"required":["name","price"],"type":"object","properties":{"name":{"type":"string"},"price":{"type":"number","format":"double"}}},"RequestOrder":{"required":["items","restaurant","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int64"},"restaurant":{"type":"string"},"items":{"type":"array","items":{"$ref":"#/components/schemas/RequestItem"}}}},"MenuDTO":{"required":["name","price"],"type":"object","properties":{"name":{"type":"string"},"price":{"type":"number"}}},"OrderResponseDTO":{"required":["items","orderId","restaurant"],"type":"object","properties":{"orderId":{"type":"integer","format":"int64"},"restaurant":{"type":"string"},"items":{"type":"array","items":{"$ref":"#/components/schemas/RequestItem"}},"timeOrdered":{"type":"string"}}},"MenuEntity":{"required":["name","price"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"price":{"type":"number"}}}}}} \ No newline at end of file diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/OrderingApplication.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/OrderingApplication.kt new file mode 100644 index 0000000..ad3e6f1 --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/OrderingApplication.kt @@ -0,0 +1,18 @@ +package com.coded.spring.ordering + +import com.hazelcast.config.Config +import com.hazelcast.core.Hazelcast +import com.hazelcast.core.HazelcastInstance +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class OrderingApplication + +fun main(args: Array) { + runApplication(*args) + orderConfig.getMapConfig("menus").setTimeToLiveSeconds(60) +} + +val orderConfig = Config("order-cache") +val serverCache: HazelcastInstance = Hazelcast.newHazelcastInstance(orderConfig) \ No newline at end of file diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/WelcomeController.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/WelcomeController.kt new file mode 100644 index 0000000..0676637 --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/WelcomeController.kt @@ -0,0 +1,21 @@ +package com.coded.spring.ordering + +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.beans.factory.annotation.Value +import org.springframework.web.bind.annotation.* + +@RestController +@Tag(name = "WelcomeAPI") +class WelcomeController( + @Value("\${company-name}") + private val companyName: String, + @Value("\${feature.festive.enabled}") + private val festiveIsEnabled: Boolean +) { + @GetMapping("/welcome") + fun greetUser() = if(!festiveIsEnabled){ + "Welcome to online ordering by $companyName" + } else { + "Eidkom Mubarak!" + } +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/client/AuthenticationClient.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/client/AuthenticationClient.kt new file mode 100644 index 0000000..b35bb33 --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/client/AuthenticationClient.kt @@ -0,0 +1,29 @@ +package com.coded.spring.ordering.client + +import com.coded.spring.authentication.CheckTokenResponse +import jakarta.inject.Named +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.* +import org.springframework.util.MultiValueMap +import org.springframework.web.client.RestTemplate +import org.springframework.web.client.exchange + +@Named +class AuthenticationClient { + + fun checkToken(token: String): CheckTokenResponse { + val restTemplate = RestTemplate() + val url = "http://localhost:9002/authentication/check-token" + val response = restTemplate.exchange( + url = url, + method = HttpMethod.POST, + requestEntity = HttpEntity( + MultiValueMap.fromMultiValue(mapOf("Authorization" to listOf("Bearer $token"))) + ), + object : ParameterizedTypeReference() { + } + ) + return response.body ?: throw IllegalStateException("Check token response has no body ...") + } + +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/items/ItemsRepository.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/items/ItemsRepository.kt new file mode 100644 index 0000000..d4d449b --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/items/ItemsRepository.kt @@ -0,0 +1,29 @@ +package com.coded.spring.ordering.items + +import com.coded.spring.ordering.orders.OrderEntity +import com.fasterxml.jackson.annotation.JsonBackReference +import jakarta.inject.Named +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository + +@Named +interface ItemsRepository: JpaRepository + +@Entity +@Table(name = "items") +data class ItemsEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + // maps each item to its parent order using the foreign key items.order_id → orders.id + @ManyToOne + @JoinColumn(name = "order_id") + @JsonBackReference + val order: OrderEntity, + val name: String, + + val price: Double +){ + constructor() : this(null, OrderEntity(), "", 0.0) +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/menus/MenuController.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/menus/MenuController.kt new file mode 100644 index 0000000..486b614 --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/menus/MenuController.kt @@ -0,0 +1,23 @@ +package com.coded.spring.ordering.menus + +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.* +import java.math.BigDecimal + +@Tag(name="MenuAPI") +@RestController +class MenuController( + private val menuService: MenuService +) { + @GetMapping("/menus") + fun listMenu() = menuService.listMenu() + + @PostMapping("/menus") + fun createMenu(@RequestBody menu: MenuDTO): MenuDTO = menuService.addMenu(menu) +} + + +data class MenuDTO( + val name: String, + val price: BigDecimal +) diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/menus/MenuRepository.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/menus/MenuRepository.kt new file mode 100644 index 0000000..8db7513 --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/menus/MenuRepository.kt @@ -0,0 +1,26 @@ +package com.coded.spring.ordering.menus + +import com.coded.spring.ordering.items.ItemsEntity +import com.fasterxml.jackson.annotation.JsonManagedReference +import jakarta.inject.Named +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository +import org.hibernate.annotations.CreationTimestamp +import java.math.BigDecimal +import java.time.LocalDateTime + +@Named +interface MenuRepository : JpaRepository + +@Entity +@Table(name = "menus") +data class MenuEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + val name: String, + @Column(precision = 9, scale = 3) + val price: BigDecimal +){ + constructor() : this(null, "", BigDecimal.ZERO) +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/menus/MenuService.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/menus/MenuService.kt new file mode 100644 index 0000000..6b409ee --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/menus/MenuService.kt @@ -0,0 +1,53 @@ +package com.coded.spring.ordering.menus + +import com.coded.spring.ordering.serverCache +import com.hazelcast.logging.Logger +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.math.RoundingMode + +private val logger = Logger.getLogger("menus") + +@Service +class MenuService( + private val menuRepository: MenuRepository, + @Value("\${feature.festive.enabled}") + private val festiveIsEnabled: Boolean +) { + fun listMenu(): List { + val menusCache = serverCache.getMap>("menus") + if (menusCache["menus"]?.size == 0 || menusCache["menus"] == null) { + logger.info("No menus found, caching new data...") + if(festiveIsEnabled) { + val menus = menuRepository.findAll().map { it.copy( + price = it.price + .multiply(BigDecimal("0.8")) + .setScale(3, RoundingMode.HALF_UP)) + } + menusCache.put("menus", menus) + return menus + } + else { + val menus = menuRepository.findAll() + menusCache.put("menus", menus) + return menus + } + } + logger.info("returning ${menusCache["menus"]?.size} menu items") + return menusCache["menus"] ?: listOf() + } + + + fun addMenu(dto: MenuDTO): MenuDTO { + val newMenu = MenuEntity( + name = dto.name, + price = dto.price + ) + menuRepository.save(newMenu) + + val menusCache = serverCache.getMap>("menus") + menusCache.remove("menus") + return MenuDTO(newMenu.name, newMenu.price) + } +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/orders/OnlineOrderingController.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/orders/OnlineOrderingController.kt new file mode 100644 index 0000000..7189fca --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/orders/OnlineOrderingController.kt @@ -0,0 +1,46 @@ +package com.coded.spring.ordering.orders + +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@Tag(name="OrderAPI") +@RestController +class OnlineOrderingController( + private val onlineOrderingService: OnlineOrderingService +) { + + @GetMapping("/orders") + fun getOrders(request: HttpServletRequest): List { + val userId = request.getAttribute("userId") as Long + return onlineOrderingService.getOrders(userId) + } + + @PostMapping("/orders/add") + fun addOrders(request: HttpServletRequest, @RequestBody body: RequestOrder): ResponseEntity{ + val userId = request.getAttribute("userId") as Long + return onlineOrderingService.addOrders(userId ,body) + } +} + + + +// the DTO (Data Transfer Object) for our orders and items list +data class RequestItem( + val name: String, + val price: Double +) + +data class RequestOrder( + val userId: Long, + val restaurant: String, + val items: List +) + +data class OrderResponseDTO( + val orderId: Long, + val restaurant: String, + val items: List, + val timeOrdered: String? +) diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/orders/OnlineOrderingRepository.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/orders/OnlineOrderingRepository.kt new file mode 100644 index 0000000..044c9a2 --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/orders/OnlineOrderingRepository.kt @@ -0,0 +1,39 @@ +package com.coded.spring.ordering.orders + +import com.coded.spring.ordering.items.ItemsEntity +import com.fasterxml.jackson.annotation.JsonManagedReference +import jakarta.inject.Named +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository +import org.hibernate.annotations.CreationTimestamp +import java.time.LocalDateTime + +@Named +interface OrderRepository: JpaRepository { + fun findByUserId(userId: Long): List +} + +@Entity +@Table(name = "orders") +data class OrderEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + var userId: Long? = null, + + var restaurant: String, + + // Binds each order to its related children items using the primary key orders.id → items.order_id + @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) + @JsonManagedReference + val items: List? = null, + + + + @CreationTimestamp + var timeOrdered: LocalDateTime? = null + +){ + constructor() : this(null, null, "", null, null) +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/orders/OnlineOrderingService.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/orders/OnlineOrderingService.kt new file mode 100644 index 0000000..c9be77c --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/orders/OnlineOrderingService.kt @@ -0,0 +1,69 @@ +package com.coded.spring.ordering.orders + +import com.coded.spring.ordering.items.ItemsEntity +import com.coded.spring.ordering.items.ItemsRepository +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service + +@Service +class OnlineOrderingService( + private val orderRepository: OrderRepository, + private val itemsRepository: ItemsRepository, +) { + fun getOrders(userId: Long): List { + //return orderRepository.findAll().filter { it.userId != null }.sortedBy { it.timeOrdered } + return orderRepository.findByUserId(userId).filter { it.userId != null }.sortedBy { it.timeOrdered }.map { + order -> OrderResponseDTO( + orderId = order.id ?: + throw IllegalStateException("Order has no id..."), + restaurant = order.restaurant, + items = order.items!!.map { + RequestItem( + name = it.name, + price = it.price + ) + }, + timeOrdered = order.timeOrdered.toString() + ) + } + } + fun addOrders(userId: Long, request: RequestOrder): ResponseEntity { + val user = orderRepository.findById(request.userId).orElse(null) + ?: return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to "user with ID ${request.userId} was not found")) + + if (request.items.any { it.price < 0.0 }) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(mapOf("error" to "item price cannot be negative")) + } + + val order = orderRepository.save( + OrderEntity( + userId = userId, + restaurant = request.restaurant + ) + ) + + val items = request.items.map { item -> + ItemsEntity( + order = order, + name = item.name, + price = item.price + ) + } + + itemsRepository.saveAll(items) + + return ResponseEntity.status(HttpStatus.OK).body( + OrderResponseDTO( + orderId = order.id!!, + restaurant = order.restaurant, + timeOrdered = order.timeOrdered.toString(), + items = items.map { + RequestItem(name = it.name, price = it.price) + } + ) + ) + } +} + diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/security/RemoteAuthenticationFilter.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/security/RemoteAuthenticationFilter.kt new file mode 100644 index 0000000..91b6973 --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/security/RemoteAuthenticationFilter.kt @@ -0,0 +1,32 @@ +package com.coded.spring.ordering.security + +import com.coded.spring.ordering.client.AuthenticationClient +import jakarta.servlet.FilterChain +import jakarta.servlet.http.* +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class RemoteAuthenticationFilter( + private val authenticationClient: AuthenticationClient, +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + logger.info("Remote authentication filter running...") + val authHeader = request.getHeader("Authorization") + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response) + return + } + + val token = authHeader.substring(7) + val result = authenticationClient.checkToken(token) + request.setAttribute("userId", result.userId) + + filterChain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/com/coded/spring/ordering/security/SecurityConfig.kt b/ordering/src/main/kotlin/com/coded/spring/ordering/security/SecurityConfig.kt new file mode 100644 index 0000000..683e7be --- /dev/null +++ b/ordering/src/main/kotlin/com/coded/spring/ordering/security/SecurityConfig.kt @@ -0,0 +1,30 @@ +package com.coded.spring.ordering.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val remoteAuthFilter: RemoteAuthenticationFilter +) { + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http.csrf { it.disable() } + .authorizeHttpRequests { + it.anyRequest().permitAll() + } + .sessionManagement { + it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + .addFilterBefore(remoteAuthFilter, UsernamePasswordAuthenticationFilter::class.java) + + return http.build() + } +} \ No newline at end of file diff --git a/ordering/src/main/resources/application.properties b/ordering/src/main/resources/application.properties new file mode 100644 index 0000000..5016f3a --- /dev/null +++ b/ordering/src/main/resources/application.properties @@ -0,0 +1,8 @@ +spring.application.name=Kotlin.SpringbootV2 +server.port=9001 +spring.datasource.url=jdbc:postgresql://localhost:5432/OnlineOrderingDatabase +spring.datasource.username=postgres +spring.datasource.password=123 +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +springdoc.api-docs.path=/api-docs \ No newline at end of file diff --git a/pom.xml b/pom.xml index 163ad53..152bc3c 100644 --- a/pom.xml +++ b/pom.xml @@ -9,8 +9,9 @@ com.coded.spring - Ordering + Kotlin.SpringbootV2 0.0.1-SNAPSHOT + pom Kotlin.SpringbootV2 Kotlin.SpringbootV2 @@ -20,6 +21,10 @@ + + authentication + ordering + @@ -53,11 +58,81 @@ spring-boot-starter-test test + + org.postgresql + postgresql + compile + org.jetbrains.kotlin kotlin-test-junit5 test + + jakarta.inject + jakarta.inject-api + + + org.springframework.boot + spring-boot-starter-data-jpa + + + jakarta.validation + jakarta.validation-api + 3.0.2 + + + org.springframework.boot + spring-boot-starter-security + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + runtime + 0.11.5 + + + io.cucumber + cucumber-java + 7.20.1 + test + + + io.cucumber + cucumber-spring + 7.14.0 + test + + + io.jsonwebtoken + jjwt-jackson + runtime + 0.11.5 + + + com.hazelcast + hazelcast + 5.5.0 + + + org.springdoc + springdoc-openapi-starter-webmvc-api + 2.6.0 + + + com.h2database + h2 + test + + + org.junit.jupiter + junit-jupiter-api + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3704dc6..fa5d06a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,8 @@ spring.application.name=Kotlin.SpringbootV2 +server.port=9003 +spring.datasource.url=jdbc:postgresql://localhost:5432/OnlineOrderingDatabase +spring.datasource.username=postgres +spring.datasource.password=123 +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +springdoc.api-docs.path=/api-docs \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/ApplicationTests.kt b/src/test/kotlin/com/coded/spring/ordering/ApplicationTests.kt index b2e2320..a3e3079 100644 --- a/src/test/kotlin/com/coded/spring/ordering/ApplicationTests.kt +++ b/src/test/kotlin/com/coded/spring/ordering/ApplicationTests.kt @@ -1,13 +1,391 @@ package com.coded.spring.ordering +import com.coded.spring.ordering.authentication.jwt.JwtService +import com.coded.spring.ordering.menus.MenuDTO +import com.coded.spring.ordering.orders.OrderResponseDTO +import com.coded.spring.ordering.orders.RequestItem +import com.coded.spring.ordering.orders.RequestOrder +import com.coded.spring.ordering.profiles.RequestProfileDTO +import com.coded.spring.ordering.users.CreateUserRequest +import com.coded.spring.ordering.users.UserEntity +import com.coded.spring.authentication.users.UserRepository +import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.test.context.ActiveProfiles +import org.springframework.util.MultiValueMap +import java.math.BigDecimal +import kotlin.test.assertEquals -@SpringBootTest +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = ["src/test/resources/application-test.properties"] +) +@ActiveProfiles("test") class ApplicationTests { + companion object { + lateinit var savedUser: UserEntity + @JvmStatic + @BeforeAll + fun setUp( + @Autowired userRepository: UserRepository, + @Autowired passwordEncoder: PasswordEncoder + ) { + userRepository.deleteAll() + val user = UserEntity( + username = "momo1234", + password = passwordEncoder.encode("123dB45") + ) + savedUser = userRepository.save(user) + } + } + + @Autowired + lateinit var restTemplate: TestRestTemplate + + @Test + fun `test hello endpoint with JWT`(@Autowired jwtService: JwtService) { + val token = jwtService.generateToken("momo1234") + val headers = HttpHeaders( + MultiValueMap.fromSingleValue(mapOf("Authorization" to "Bearer $token")) + ) + val request = HttpEntity(headers) + + val result = restTemplate.exchange( + "/hello", + HttpMethod.GET, + request, + String::class.java + ) + + assertEquals(HttpStatus.OK, result.statusCode) + assertEquals("Hello World", result.body) + } + + @Test + fun `adding a new user with correct parameters should work`() { + val request = CreateUserRequest(username = "mohammed67234", password = "12Ln34567") + val result = restTemplate.postForEntity("/register", request, String::class.java) + assertEquals(HttpStatus.OK, result.statusCode) + } + + @Test + fun `adding a new user with with username already existing should NOT work`() { + val request = CreateUserRequest(username = "mohammed67234", password = "12Ln34567") + val result = restTemplate.postForEntity("/register", request, String::class.java) + assertEquals(HttpStatus.BAD_REQUEST, result.statusCode) + assertEquals( + """{"error":"username ${request.username} already exists"}""", + result.body + ) + } + + @Test + fun `adding a new user with with password having less than six chars should NOT work`() { + val request = CreateUserRequest(username = "mohammed6734234", password = "12") + val result = restTemplate.postForEntity("/register", request, String::class.java) + assertEquals(HttpStatus.BAD_REQUEST, result.statusCode) + assertEquals( + """{"error":"password must be at least 6 characters"}""", + result.body + ) + } + + @Test + fun `adding a new user with with password not having a capital letter should NOT work`() { + val request = CreateUserRequest(username = "mohamed672345324", password = "1234567n") + val result = restTemplate.postForEntity("/register", request, String::class.java) + assertEquals(HttpStatus.BAD_REQUEST, result.statusCode) + assertEquals( + """{"error":"password must have at least one capital letter"}""", + result.body + ) + } + + @Test + fun `adding a new user with with password not having a digit should NOT work`() { + val request = CreateUserRequest(username = "mohammmed67234", password = "Mohammedss") + val result = restTemplate.postForEntity("/register", request, String::class.java) + assertEquals(HttpStatus.BAD_REQUEST, result.statusCode) + assertEquals( + """{"error":"password must have at least one digit"}""", + result.body + ) + } + + @Test + fun `adding a new order with correct parameters should work`(@Autowired jwtService: JwtService) { + + val token = jwtService.generateToken("momo1234") + val headers = HttpHeaders( + MultiValueMap.fromSingleValue(mapOf("Authorization" to "Bearer $token")) + ) + + val body = RequestOrder( + userId = savedUser.id!!, + restaurant = "WK", + items = listOf(RequestItem("Nuggies", 3.950)) + ) + + val requestEntity = HttpEntity(body, headers) + val actualResponse = restTemplate.exchange( + "/orders/add", //Endpoint + HttpMethod.POST, + requestEntity, + OrderResponseDTO::class.java + ) + + assertEquals(HttpStatus.OK, actualResponse.statusCode) + + val responseBody = actualResponse.body!! + assertEquals("momo1234", responseBody.username) + assertEquals("WK", responseBody.restaurant) + assertEquals(listOf(RequestItem("Nuggies", 3.950)), responseBody.items) + + val now = System.currentTimeMillis() + val orderTime = java.time.LocalDateTime + .parse(responseBody.timeOrdered) + .atZone(java.time.ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + assert(orderTime <= now && orderTime > now - 1000) { + "Expected timeOrdered to be recent. Got: ${responseBody.timeOrdered}" + } + + + } + + @Test + fun `adding a new order with incorrect user id should NOT work`(@Autowired jwtService: JwtService) { + + val token = jwtService.generateToken("momo1234") + val headers = HttpHeaders( + MultiValueMap.fromSingleValue(mapOf("Authorization" to "Bearer $token")) + ) + + val body = RequestOrder( + userId = 900, + restaurant = "WK", + items = listOf(RequestItem("Nuggies", 3.950)) + ) + + val requestEntity = HttpEntity(body, headers) + val actualResponse = restTemplate.exchange( + "/orders/add", //Endpoint + HttpMethod.POST, + requestEntity, + String::class.java + ) + + assertEquals(HttpStatus.BAD_REQUEST, actualResponse.statusCode) + assertEquals( + """{"error":"user with ID ${requestEntity.body?.userId} was not found"}""", + actualResponse.body + ) + } + + @Test + fun `adding a new order with negative item price should NOT work`(@Autowired jwtService: JwtService) { + + val token = jwtService.generateToken("momo1234") + val headers = HttpHeaders( + MultiValueMap.fromSingleValue(mapOf("Authorization" to "Bearer $token")) + ) + + val body = RequestOrder( + userId = savedUser.id!!, + restaurant = "WK", + items = listOf(RequestItem("Nuggies", -3.950)) + ) + + val requestEntity = HttpEntity(body, headers) + val actualResponse = restTemplate.exchange( + "/orders/add", //Endpoint + HttpMethod.POST, + requestEntity, + String::class.java + ) + + assertEquals(HttpStatus.BAD_REQUEST, actualResponse.statusCode) + assertEquals( + """{"error":"item price cannot be negative"}""", + actualResponse.body + ) + + } + + @Test + fun `adding new profile should work`(@Autowired jwtService: JwtService) { + val token = jwtService.generateToken("momo1234") + val headers = HttpHeaders() + headers.set("Authorization", "Bearer $token") + + val requestBody = RequestProfileDTO( + firstName = "Mohammed", + lastName = "Sheshtar", + phoneNumber = "12345678" + ) + + val entity = HttpEntity(requestBody, headers) + + val response = restTemplate.exchange( + "/profile", + HttpMethod.POST, + entity, + String::class.java + ) + + assertEquals(HttpStatus.OK, response.statusCode) + } + + @Test + fun `adding new profile with first name having numbers should NOT work`(@Autowired jwtService: JwtService) { + val token = jwtService.generateToken("momo1234") + val headers = HttpHeaders() + headers.set("Authorization", "Bearer $token") + + val requestBody = RequestProfileDTO( + firstName = "Mohammed111", + lastName = "Sheshtar", + phoneNumber = "12345678" + ) + + val entity = HttpEntity(requestBody, headers) + + val response = restTemplate.exchange( + "/profile", + HttpMethod.POST, + entity, + String::class.java + ) + + assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) + assertEquals( + """{"error":"first name must not contain any numbers"}""", + response.body + ) + } + + @Test + fun `adding new profile with last name having numbers should NOT work`(@Autowired jwtService: JwtService) { + val token = jwtService.generateToken("momo1234") + val headers = HttpHeaders() + headers.set("Authorization", "Bearer $token") + + val requestBody = RequestProfileDTO( + firstName = "Mohammed", + lastName = "Sheshtar222", + phoneNumber = "12345678" + ) + + val entity = HttpEntity(requestBody, headers) + + val response = restTemplate.exchange( + "/profile", + HttpMethod.POST, + entity, + String::class.java + ) + + assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) + assertEquals( + """{"error":"last name must not contain any numbers"}""", + response.body + ) + } + + @Test + fun `adding new profile with phone number not being eight digits should NOT work`(@Autowired jwtService: JwtService) { + val token = jwtService.generateToken("momo1234") + val headers = HttpHeaders() + headers.set("Authorization", "Bearer $token") + + val requestBody = RequestProfileDTO( + firstName = "Mohammed", + lastName = "Sheshtar", + phoneNumber = "12378" + ) + + val entity = HttpEntity(requestBody, headers) + + val response = restTemplate.exchange( + "/profile", + HttpMethod.POST, + entity, + String::class.java + ) + + assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) + assertEquals( + """{"error":"phone number must be 8 digits"}""", + response.body + ) + } + @Test - fun contextLoads() { + fun `fetching list of orders should work`(@Autowired jwtService: JwtService) { + val token = jwtService.generateToken("momo1234") + val headers = HttpHeaders() + headers.set("Authorization", "Bearer $token") + + val request = HttpEntity(headers) + + val result = restTemplate.exchange( + "/orders", + HttpMethod.GET, + request, + String::class.java + ) + + assertEquals(HttpStatus.OK, result.statusCode) } + @Test + fun `adding a menu should work`(@Autowired jwtService: JwtService) { + val token = jwtService.generateToken("momo1234") + val headers = HttpHeaders() + headers.set("Authorization", "Bearer $token") + + val requestBody = MenuDTO( + name = "Hamburga", + price = BigDecimal.TWO + ) + + val entity = HttpEntity(requestBody, headers) + + val response = restTemplate.exchange( + "/menus", + HttpMethod.POST, + entity, + String::class.java + ) + + assertEquals(HttpStatus.OK, response.statusCode) + } + + @Test + fun `fetching list of menus should work`(@Autowired jwtService: JwtService) { + val token = jwtService.generateToken("momo1234") + val headers = HttpHeaders() + headers.set("Authorization", "Bearer $token") + + val request = HttpEntity(headers) + + val result = restTemplate.exchange( + "/menus", + HttpMethod.GET, + request, + String::class.java + ) + + assertEquals(HttpStatus.OK, result.statusCode) + } } diff --git a/src/test/kotlin/com/coded/spring/ordering/CucumberRunner.kt b/src/test/kotlin/com/coded/spring/ordering/CucumberRunner.kt new file mode 100644 index 0000000..a2297bc --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/CucumberRunner.kt @@ -0,0 +1,12 @@ +package com.coded.spring.ordering + +import io.cucumber.spring.CucumberContextConfiguration +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles + +@CucumberContextConfiguration +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = ["src/test/resources/application-test.properties"] +) +@ActiveProfiles("test") +class CucumberSpringConfiguration diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/AuthenticationSteps.kt b/src/test/kotlin/com/coded/spring/ordering/steps/AuthenticationSteps.kt new file mode 100644 index 0000000..9e4a9b0 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/AuthenticationSteps.kt @@ -0,0 +1,47 @@ +package com.coded.spring.ordering.steps + +import io.cucumber.java.en.Given +import io.cucumber.java.en.When +import io.cucumber.java.en.Then +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.ResponseEntity +import kotlin.test.assertEquals + +class AuthSteps { + + @Autowired + lateinit var restTemplate: TestRestTemplate + + @Autowired + lateinit var jwtService: com.coded.spring.ordering.authentication.jwt.JwtService + + private lateinit var headers: HttpHeaders + private lateinit var response: ResponseEntity + + @Given("a valid JWT token for user {string}") + fun givenAValidJwtToken(username: String) { + val token = jwtService.generateToken(username) + headers = HttpHeaders() + headers.set("Authorization", "Bearer $token") + } + + @When("I send a GET request to {string} with the token") + fun iSendAGetRequestWithToken(endpoint: String) { + val request = HttpEntity(headers) + response = restTemplate.exchange(endpoint, HttpMethod.GET, request, String::class.java) + } + + @Then("the response status should be {int}") + fun theResponseStatusShouldBe(expectedStatus: Int) { + assertEquals(expectedStatus, response.statusCode.value()) + } + + @Then("the response body should be {string}") + fun theResponseBodyShouldBe(expectedBody: String) { + assertEquals(expectedBody, response.body) + } +} diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/MenusSteps.kt b/src/test/kotlin/com/coded/spring/ordering/steps/MenusSteps.kt new file mode 100644 index 0000000..9bd7d47 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/MenusSteps.kt @@ -0,0 +1,2 @@ +package com.coded.spring.ordering.steps + diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/OrdersSteps.kt b/src/test/kotlin/com/coded/spring/ordering/steps/OrdersSteps.kt new file mode 100644 index 0000000..9bd7d47 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/OrdersSteps.kt @@ -0,0 +1,2 @@ +package com.coded.spring.ordering.steps + diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/ProfilesSteps.kt b/src/test/kotlin/com/coded/spring/ordering/steps/ProfilesSteps.kt new file mode 100644 index 0000000..9bd7d47 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/ProfilesSteps.kt @@ -0,0 +1,2 @@ +package com.coded.spring.ordering.steps + diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/UserSteps.kt b/src/test/kotlin/com/coded/spring/ordering/steps/UserSteps.kt new file mode 100644 index 0000000..9bd7d47 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/UserSteps.kt @@ -0,0 +1,2 @@ +package com.coded.spring.ordering.steps + diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..f1133ab --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,11 @@ +spring.application.name=Kotlin.SpringbootV2 +server.port=9001 +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console diff --git a/src/test/resources/features/authentication.feature b/src/test/resources/features/authentication.feature new file mode 100644 index 0000000..4b74601 --- /dev/null +++ b/src/test/resources/features/authentication.feature @@ -0,0 +1,7 @@ +Feature: Authentication + + Scenario: Access hello endpoint with a valid JWT token + Given a valid JWT token for user "momo1234" + When I send a GET request to "/hello" with the token + Then the response status should be 200 + And the response body should be "Hello World" diff --git a/src/test/resources/features/menus.feature b/src/test/resources/features/menus.feature new file mode 100644 index 0000000..e69de29 diff --git a/src/test/resources/features/orders.feature b/src/test/resources/features/orders.feature new file mode 100644 index 0000000..e69de29 diff --git a/src/test/resources/features/profiles.feature b/src/test/resources/features/profiles.feature new file mode 100644 index 0000000..e69de29 diff --git a/src/test/resources/features/users.feature b/src/test/resources/features/users.feature new file mode 100644 index 0000000..e69de29