diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3dbc241..e3e7ec8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,6 +12,8 @@ repositories { dependencies { implementation(projects.identity) implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + runtimeOnly("org.postgresql:postgresql") implementation("org.jetbrains.kotlin:kotlin-reflect") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") diff --git a/app/src/main/kotlin/org/spendoo/app/SpendooApplication.kt b/app/src/main/kotlin/org/spendoo/app/SpendooApplication.kt index e75330d..5628920 100644 --- a/app/src/main/kotlin/org/spendoo/app/SpendooApplication.kt +++ b/app/src/main/kotlin/org/spendoo/app/SpendooApplication.kt @@ -3,9 +3,13 @@ package org.spendoo.app import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication import org.springframework.context.annotation.ComponentScan +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.boot.persistence.autoconfigure.EntityScan @SpringBootApplication @ComponentScan(basePackages = ["org.spendoo"]) +@EnableJpaRepositories(basePackages = ["org.spendoo"]) +@EntityScan(basePackages = ["org.spendoo"]) class SpendooApplication fun main(args: Array) { diff --git a/app/src/main/resources/application.properties b/app/src/main/resources/application.properties index 1e6ca6c..237492e 100644 --- a/app/src/main/resources/application.properties +++ b/app/src/main/resources/application.properties @@ -1 +1,31 @@ spring.application.name=app + +# DATABASE +# =============================== +spring.datasource.url=${DATABASE_URL} +spring.datasource.username=${DATABASE_USERNAME} +spring.datasource.password=${DATABASE_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver + +# =============================== +# JPA +# =============================== +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true + +# =============================== +# MAIL CONFIGURATION +# =============================== +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${MAIL_USERNAME} +spring.mail.password=${MAIL_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +# =============================== +# JWT SECRET KEY +# =============================== +jwt.secret-key=${JWT_SECRET} diff --git a/identity/build.gradle.kts b/identity/build.gradle.kts index 192f9a5..0f22d8a 100644 --- a/identity/build.gradle.kts +++ b/identity/build.gradle.kts @@ -3,6 +3,7 @@ plugins { kotlin("plugin.spring") version "2.2.21" id("org.springframework.boot") version "4.0.1" id("io.spring.dependency-management") version "1.1.7" + kotlin("plugin.jpa") version "1.9.22" } repositories { @@ -11,6 +12,15 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-web") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + runtimeOnly("org.postgresql:postgresql") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("io.jsonwebtoken:jjwt-api:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-mail") implementation("org.jetbrains.kotlin:kotlin-reflect") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") @@ -20,3 +30,7 @@ dependencies { tasks.withType { useJUnitPlatform() } +kotlin { + jvmToolchain(21) +} + diff --git a/identity/src/main/kotlin/org/spendoo/identity/controller/AuthController.kt b/identity/src/main/kotlin/org/spendoo/identity/controller/AuthController.kt new file mode 100644 index 0000000..d22f676 --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/controller/AuthController.kt @@ -0,0 +1,47 @@ +package org.spendoo.identity.controller + +import jakarta.validation.Valid +import org.spendoo.identity.dto.response.AuthResponse +import org.spendoo.identity.dto.request.LoginRequest +import org.spendoo.identity.dto.request.RefreshTokenRequest +import org.spendoo.identity.dto.request.RegisterRequest +import org.spendoo.identity.service.AuthService +import org.springframework.http.HttpStatus +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.RequestMapping +import org.springframework.web.bind.annotation.RestController + + +@RestController +@RequestMapping("/api/v1/auth") +class AuthController ( + private val authService: AuthService +) { + + @PostMapping("/signup") + fun register (@Valid @RequestBody request: RegisterRequest): ResponseEntity { + val response = authService.register(request) + return ResponseEntity.status(HttpStatus.CREATED).body(response) + } + + @PostMapping("/login") + fun login (@Valid @RequestBody request: LoginRequest): ResponseEntity { + val response = authService.login(request) + return ResponseEntity.ok(response) + } + + @PostMapping("/refresh") + fun refresh (@Valid @RequestBody request: RefreshTokenRequest): ResponseEntity { + val response = authService.refreshToken(request) + return ResponseEntity.ok(response) + } + + @PostMapping("/logout") + fun logout(@Valid @RequestBody request: RefreshTokenRequest): ResponseEntity> { + authService.logout(request) + return ResponseEntity.ok(mapOf("message" to "Logged out successfully")) + } + +} \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/dto/request/ForgotPasswordRequest.kt b/identity/src/main/kotlin/org/spendoo/identity/dto/request/ForgotPasswordRequest.kt new file mode 100644 index 0000000..2db185b --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/dto/request/ForgotPasswordRequest.kt @@ -0,0 +1,10 @@ +package org.spendoo.identity.dto.request + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank + +data class ForgotPasswordRequest( + @field:NotBlank(message = "Email is required") + @field:Email(message = "Invalid email format") + val email: String +) \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/dto/request/LoginRequest.kt b/identity/src/main/kotlin/org/spendoo/identity/dto/request/LoginRequest.kt new file mode 100644 index 0000000..b2a3dba --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/dto/request/LoginRequest.kt @@ -0,0 +1,18 @@ +package org.spendoo.identity.dto.request + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern + +data class LoginRequest( + @field:NotBlank(message = "Email is required") + @field:Email(message = "Invalid email format") + val email: String, + + @field:NotBlank(message = "Password is required") + @field:Pattern( + regexp = """^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!]).{8,}$""", + message = "Password must contain at least 8 characters, one uppercase, one lowercase, one number and one special character" + ) + val password: String +) \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/dto/request/RefreshTokenRequest.kt b/identity/src/main/kotlin/org/spendoo/identity/dto/request/RefreshTokenRequest.kt new file mode 100644 index 0000000..43321b2 --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/dto/request/RefreshTokenRequest.kt @@ -0,0 +1,9 @@ +package org.spendoo.identity.dto.request + +import jakarta.validation.constraints.NotBlank + +data class RefreshTokenRequest( + + @field:NotBlank(message = "Refresh token is required") + val refreshToken: String +) \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/dto/request/RegisterRequest.kt b/identity/src/main/kotlin/org/spendoo/identity/dto/request/RegisterRequest.kt new file mode 100644 index 0000000..0f05169 --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/dto/request/RegisterRequest.kt @@ -0,0 +1,32 @@ +package org.spendoo.identity.dto.request + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Past +import jakarta.validation.constraints.Pattern +import org.spendoo.identity.enums.Gender +import java.time.LocalDate + +data class RegisterRequest( + @field:NotBlank(message = "Full name is required") + val fullName: String, + + @field:NotBlank(message = "Email is required") + @field:Email(message = "Please provide a valid email address") + val email: String, + + @field:NotBlank(message = "Password is required") + @field:Pattern( + regexp = """^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!]).{8,}$""", + message = "Password must contain at least 8 characters, one uppercase, one lowercase, one number and one special character" + ) + val password: String, + + @field:NotNull(message = "Gender is required") + val gender: Gender, + + @field:NotNull(message = "Birth date is required") + @field:Past(message = "Birth date must be in the past") + val birthDate: LocalDate +) \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/dto/request/ResetPasswordRequest.kt b/identity/src/main/kotlin/org/spendoo/identity/dto/request/ResetPasswordRequest.kt new file mode 100644 index 0000000..c6d9328 --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/dto/request/ResetPasswordRequest.kt @@ -0,0 +1,23 @@ +package org.spendoo.identity.dto.request + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size + +data class ResetPasswordRequest( + @field:NotBlank(message = "Email is required") + @field:Email(message = "Invalid email format") + val email: String, + + @field:NotBlank(message = "OTP is required") + @field:Size(min = 4, max = 4, message = "OTP must be exactly 4 characters") + val otp: String, + + @field:NotBlank(message = "New password is required") + @field:Pattern( + regexp = """^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!]).{8,}$""", + message = "Password must contain at least 8 characters, one uppercase, one lowercase, one number and one special character" + ) + val newPassword: String +) \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/dto/request/VerifyOtpRequest.kt b/identity/src/main/kotlin/org/spendoo/identity/dto/request/VerifyOtpRequest.kt new file mode 100644 index 0000000..663f05c --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/dto/request/VerifyOtpRequest.kt @@ -0,0 +1,15 @@ +package org.spendoo.identity.dto.request + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class VerifyOtpRequest( + @field:NotBlank(message = "Email is required") + @field:Email(message = "Invalid email format") + val email: String, + + @field:NotBlank(message = "OTP is required") + @field:Size(min = 4, max = 4, message = "OTP must be exactly 4 characters") + val otp: String +) \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/dto/response/AuthResponse.kt b/identity/src/main/kotlin/org/spendoo/identity/dto/response/AuthResponse.kt new file mode 100644 index 0000000..682ef69 --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/dto/response/AuthResponse.kt @@ -0,0 +1,6 @@ +package org.spendoo.identity.dto.response + +data class AuthResponse( + val accessToken: String, + val refreshToken: String +) \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/entity/EmailVerification.kt b/identity/src/main/kotlin/org/spendoo/identity/entity/EmailVerification.kt new file mode 100644 index 0000000..21994ea --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/entity/EmailVerification.kt @@ -0,0 +1,35 @@ +package org.spendoo.identity.entity + +import jakarta.persistence.* +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table(name = "email_verification", schema = "identity") +data class EmailVerification( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "uuid", updatable = false, nullable = false) + val id: UUID = UUID.randomUUID(), + + @Column(nullable = false) + val verificationCode: String, + + @Column(nullable = false) + val email: String, + + @Column(nullable = false) + val sentAt: LocalDateTime, + + @Column(nullable = false) + val isUsed: Boolean, + + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + val user: User +) { + fun isExpired(): Boolean { + return sentAt.plusMinutes(1).isBefore(LocalDateTime.now()) + } +} \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/entity/RefreshToken.kt b/identity/src/main/kotlin/org/spendoo/identity/entity/RefreshToken.kt new file mode 100644 index 0000000..b4ed30b --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/entity/RefreshToken.kt @@ -0,0 +1,23 @@ +package org.spendoo.identity.entity + +import jakarta.persistence.* +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table(name = "refresh_token", schema = "identity") +data class RefreshToken( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @Column(nullable = false, unique = true) + val token: String, + + @Column(nullable = false) + val expiryDate: LocalDateTime, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + val user: User +) \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/entity/User.kt b/identity/src/main/kotlin/org/spendoo/identity/entity/User.kt new file mode 100644 index 0000000..0d05c20 --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/entity/User.kt @@ -0,0 +1,41 @@ +package org.spendoo.identity.entity + +import jakarta.persistence.* +import org.spendoo.identity.enums.Gender +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table(name = "users", schema = "identity" ) +data class User( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "uuid", updatable = false, nullable = false) + val id: UUID = UUID.randomUUID(), + + @Column(nullable = false) + val fullName: String, + + @Column(nullable = false, unique = true) + val email: String, + + @Column(nullable = false) + val passwordHash: String, + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + val gender: Gender, + + @Column(nullable = false) + val birthDate: LocalDate, + + @Column(nullable = false, updatable = false) + val createdAt: LocalDateTime, + + @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + val emailVerifications: List = emptyList(), + + @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true) + val refreshTokens: List = emptyList() +) diff --git a/identity/src/main/kotlin/org/spendoo/identity/enums/Gender.kt b/identity/src/main/kotlin/org/spendoo/identity/enums/Gender.kt new file mode 100644 index 0000000..e3dbe5b --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/enums/Gender.kt @@ -0,0 +1,6 @@ +package org.spendoo.identity.enums + +enum class Gender { + MALE, + FEMALE +} \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/exception/AuthExceptions.kt b/identity/src/main/kotlin/org/spendoo/identity/exception/AuthExceptions.kt new file mode 100644 index 0000000..55f12e0 --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/exception/AuthExceptions.kt @@ -0,0 +1,16 @@ +package org.spendoo.identity.exception + +class InvalidCredentialsException( + message: String = "Invalid email or password") : RuntimeException(message) + + +class TokenExpiredException ( + message: String = "Token has expired. Please login again.") : RuntimeException(message) + + +class UnauthorizedException ( + message: String = "Unauthorized access") : RuntimeException(message) + +class UserAlreadyExistsException( + message: String = "User already exists") : RuntimeException(message) + diff --git a/identity/src/main/kotlin/org/spendoo/identity/exception/GlobalExceptionHandler.kt b/identity/src/main/kotlin/org/spendoo/identity/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..8445a0f --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/exception/GlobalExceptionHandler.kt @@ -0,0 +1,51 @@ +package org.spendoo.identity.exception + +import org.spendoo.identity.response.ErrorResponse +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + + +@RestControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(UserAlreadyExistsException::class) + fun handleUserAlreadyExists(ex: UserAlreadyExistsException): ResponseEntity { + val error = ErrorResponse(ex.message ?: "Conflict", HttpStatus.CONFLICT.value()) + return ResponseEntity.status(HttpStatus.CONFLICT).body(error) + } + + @ExceptionHandler(InvalidCredentialsException::class, BadCredentialsException::class) + fun handleInvalidCredentials(ex: Exception): ResponseEntity { + val error = ErrorResponse("Invalid email or password", HttpStatus.UNAUTHORIZED.value()) + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error) + } + + @ExceptionHandler(TokenExpiredException::class) + fun handleTokenExpired(ex: TokenExpiredException): ResponseEntity { + val error = ErrorResponse(ex.message ?: "Token expired", HttpStatus.UNAUTHORIZED.value()) + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error) + } + + @ExceptionHandler(UnauthorizedException::class) + fun handleUnauthorized(ex: UnauthorizedException): ResponseEntity { + val error = ErrorResponse(ex.message ?: "Unauthorized", HttpStatus.FORBIDDEN.value()) + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity { + val errorMessage = ex.bindingResult.allErrors.firstOrNull()?.defaultMessage ?: "Validation failed" + val error = ErrorResponse(errorMessage, HttpStatus.BAD_REQUEST.value()) + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error) + } + + @ExceptionHandler(Exception::class) + fun handleGenericException(ex: Exception): ResponseEntity { + val error = ErrorResponse(ex.message ?: "Internal Server Error", HttpStatus.INTERNAL_SERVER_ERROR.value()) // 500 + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error) + } +} \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/mapper/UserMapper.kt b/identity/src/main/kotlin/org/spendoo/identity/mapper/UserMapper.kt new file mode 100644 index 0000000..f4d2afb --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/mapper/UserMapper.kt @@ -0,0 +1,17 @@ +package org.spendoo.identity.mapper +import org.spendoo.identity.dto.request.RegisterRequest +import org.spendoo.identity.entity.User +import java.time.LocalDateTime + +fun RegisterRequest.toEntity(hashedPassword: String): User { + return User( + fullName = this.fullName, + email = this.email, + passwordHash = hashedPassword, + gender = this.gender, + birthDate = this.birthDate, + createdAt = LocalDateTime.now(), + emailVerifications = emptyList(), + refreshTokens = emptyList() + ) +} \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/repository/RefreshTokenRepository.kt b/identity/src/main/kotlin/org/spendoo/identity/repository/RefreshTokenRepository.kt new file mode 100644 index 0000000..75a40c0 --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/repository/RefreshTokenRepository.kt @@ -0,0 +1,14 @@ +package org.spendoo.identity.repository + +import org.spendoo.identity.entity.RefreshToken +import org.spendoo.identity.entity.User +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.util.Optional + +@Repository +interface RefreshTokenRepository : JpaRepository { + + fun findByToken(token: String): Optional + fun findByUser(user: User): Optional +} \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/repository/UserRepository.kt b/identity/src/main/kotlin/org/spendoo/identity/repository/UserRepository.kt new file mode 100644 index 0000000..a47fd69 --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/repository/UserRepository.kt @@ -0,0 +1,11 @@ +package org.spendoo.identity.repository + +import org.spendoo.identity.entity.User +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional +import java.util.UUID + +interface UserRepository : JpaRepository { + fun findByEmail(email: String): Optional + fun existsByEmail(email: String): Boolean +} \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/response/ErrorResponse.kt b/identity/src/main/kotlin/org/spendoo/identity/response/ErrorResponse.kt new file mode 100644 index 0000000..2c8403f --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/response/ErrorResponse.kt @@ -0,0 +1,7 @@ +package org.spendoo.identity.response + + +data class ErrorResponse( + val message: String, + val status: Int +) \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/security/CustomUserDetailsService.kt b/identity/src/main/kotlin/org/spendoo/identity/security/CustomUserDetailsService.kt new file mode 100644 index 0000000..35f98eb --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/security/CustomUserDetailsService.kt @@ -0,0 +1,25 @@ +package org.spendoo.identity.security + +import org.spendoo.identity.repository.UserRepository +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service + +@Service +class CustomUserDetailsService( + private val userRepository: UserRepository +) : UserDetailsService { + + override fun loadUserByUsername(email: String): UserDetails { + val user = userRepository.findByEmail(email) + .orElseThrow {UsernameNotFoundException("User not found with email: $email") } + + return User.builder() + .username(user.email) + .password(user.passwordHash) + .roles("USER") + .build() + } +} \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/security/JwtFilter.kt b/identity/src/main/kotlin/org/spendoo/identity/security/JwtFilter.kt new file mode 100644 index 0000000..554e688 --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/security/JwtFilter.kt @@ -0,0 +1,50 @@ +package org.spendoo.identity.security + +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.web.authentication.WebAuthenticationDetailsSource +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class JwtFilter( + private val jwtUtil: JwtUtil, + private val userDetailsService: CustomUserDetailsService +) : 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 = jwtUtil.extractUsername(token) + + if (username != null && SecurityContextHolder.getContext().authentication == null) { + if (jwtUtil.validateAccessToken(token) && + jwtUtil.validateTokenForUser(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/identity/src/main/kotlin/org/spendoo/identity/security/JwtUtil.kt b/identity/src/main/kotlin/org/spendoo/identity/security/JwtUtil.kt new file mode 100644 index 0000000..21e7bba --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/security/JwtUtil.kt @@ -0,0 +1,89 @@ +package org.spendoo.identity.security + +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.util.Base64 +import java.util.Date +import javax.crypto.SecretKey +import java.time.Duration + +@Component +class JwtUtil( + @Value("\${jwt.secret-key}") private val secret: String +){ + + private val secretKey: SecretKey by lazy { + Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret)) + } + private val accessExpiration = Duration.ofMinutes(30) + private val refreshExpiration = Duration.ofDays(14) + + // ================== Generate ================== + + private fun createToken(email: String, expirationTimeMillis: Long, type: String): String { + return Jwts.builder() + .setSubject(email) + .claim("type", type) + .setIssuedAt(Date(System.currentTimeMillis())) + .setExpiration(Date(System.currentTimeMillis() + expirationTimeMillis)) + .signWith(secretKey) + .compact() + } + + fun generateAccessToken(email: String): String { + return createToken(email, accessExpiration.toMillis(), "access") + } + + fun generateRefreshToken(email: String): String { + return createToken(email, refreshExpiration.toMillis(), "refresh") + } + + // ================== Parsing ================== + + private fun parseAllClaims(token: String) = + try { + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .body + } catch (e: Exception) { + null + } + + fun extractUsername(token: String): String? { + return parseAllClaims(token)?.subject + } + + // ================== Validation ================== + + private fun isTokenExpired(claims: Claims): Boolean { + return claims.expiration.before(Date()) + } + + private fun validateTokenInternal(token: String): Claims? { + val claims = parseAllClaims(token) ?: return null + if (isTokenExpired(claims)) return null + return claims + } + + fun validateAccessToken(token: String): Boolean { + val claims = validateTokenInternal(token) ?: return false + return claims["type"] == "access" + } + + fun validateRefreshToken(token: String): Boolean { + val claims = validateTokenInternal(token) ?: return false + return claims["type"] == "refresh" + } + + fun validateTokenForUser(token: String, username: String): Boolean { + val claims = validateTokenInternal(token) ?: return false + return claims.subject == username + } + +} + diff --git a/identity/src/main/kotlin/org/spendoo/identity/security/SecurityConfig.kt b/identity/src/main/kotlin/org/spendoo/identity/security/SecurityConfig.kt new file mode 100644 index 0000000..6d7582c --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/security/SecurityConfig.kt @@ -0,0 +1,43 @@ +package org.spendoo.identity.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authentication.AuthenticationManager +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 jwtFilter: JwtFilter +) { + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http.csrf { it.disable() } + .authorizeHttpRequests { + it.requestMatchers("/api/auth/**").permitAll() + it.anyRequest().authenticated() + } + .sessionManagement { + it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) + return http.build() + } + + @Bean + fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() + + @Bean + fun authenticationManager(authConfig: AuthenticationConfiguration): AuthenticationManager { + return authConfig.authenticationManager + } + +} \ No newline at end of file diff --git a/identity/src/main/kotlin/org/spendoo/identity/service/AuthService.kt b/identity/src/main/kotlin/org/spendoo/identity/service/AuthService.kt new file mode 100644 index 0000000..248b9d9 --- /dev/null +++ b/identity/src/main/kotlin/org/spendoo/identity/service/AuthService.kt @@ -0,0 +1,106 @@ +package org.spendoo.identity.service + +import org.spendoo.identity.dto.response.AuthResponse +import org.spendoo.identity.dto.request.LoginRequest +import org.spendoo.identity.dto.request.RefreshTokenRequest +import org.spendoo.identity.dto.request.RegisterRequest +import org.spendoo.identity.entity.RefreshToken +import org.spendoo.identity.entity.User +import org.spendoo.identity.exception.TokenExpiredException +import org.spendoo.identity.exception.UnauthorizedException +import org.spendoo.identity.exception.UserAlreadyExistsException +import org.spendoo.identity.mapper.toEntity +import org.spendoo.identity.repository.RefreshTokenRepository +import org.spendoo.identity.repository.UserRepository +import org.spendoo.identity.security.JwtUtil +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class AuthService( + private val userRepository: UserRepository, + private val refreshTokenRepository: RefreshTokenRepository, + private val passwordEncoder: PasswordEncoder, + private val jwtUtil: JwtUtil, + private val authenticationManager: AuthenticationManager +) { + + fun register(request: RegisterRequest): AuthResponse { + if (userRepository.findByEmail(request.email).isPresent) { + throw UserAlreadyExistsException("Email is already registered!") + } + + + val user = request.toEntity(passwordEncoder.encode(request.password)!!) + + userRepository.save(user) + + val accessToken = jwtUtil.generateAccessToken(user.email) + val refreshToken = jwtUtil.generateRefreshToken(user.email) + + saveRefreshToken(user, refreshToken) + + return AuthResponse(accessToken, refreshToken) + } + + + fun login(request: LoginRequest): AuthResponse { + authenticationManager.authenticate( + UsernamePasswordAuthenticationToken(request.email, request.password) + ) + + val user = userRepository.findByEmail(request.email).get() + val accessToken = jwtUtil.generateAccessToken(request.email) + val refreshToken = jwtUtil.generateRefreshToken(request.email) + + saveRefreshToken(user, refreshToken) + + return AuthResponse(accessToken, refreshToken) + } + + + fun refreshToken(request: RefreshTokenRequest): AuthResponse { + + val refreshTokenEntity = refreshTokenRepository.findByToken(request.refreshToken) + .orElseThrow { UnauthorizedException("Invalid refresh token") } + + val user = refreshTokenEntity.user + + refreshTokenRepository.delete(refreshTokenEntity) + + if (refreshTokenEntity.expiryDate.isBefore(LocalDateTime.now())) { + throw TokenExpiredException("Refresh token is expired. Please login again.") + } + + if (jwtUtil.validateRefreshToken(request.refreshToken) && + jwtUtil.validateTokenForUser(request.refreshToken, user.email)) { + + val newAccessToken = jwtUtil.generateAccessToken(user.email) + val newRefreshToken = jwtUtil.generateRefreshToken(user.email) + + + saveRefreshToken(user, newRefreshToken) + + return AuthResponse(newAccessToken, newRefreshToken) + } else { + throw UnauthorizedException("Invalid refresh token") + } + } + + private fun saveRefreshToken(user: User, token: String) { + val expiryDate = LocalDateTime.now().plusDays(14) + + refreshTokenRepository.save( + RefreshToken(token = token, expiryDate = expiryDate, user = user) + ) + } + + fun logout(request: RefreshTokenRequest) { + refreshTokenRepository.findByToken(request.refreshToken).ifPresent { tokenEntity -> + refreshTokenRepository.delete(tokenEntity) + } + } +} \ No newline at end of file