Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/kotlin/org/spendoo/app/SpendooApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
Expand Down
30 changes: 30 additions & 0 deletions app/src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -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}
14 changes: 14 additions & 0 deletions identity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Expand All @@ -20,3 +30,7 @@ dependencies {
tasks.withType<Test> {
useJUnitPlatform()
}
kotlin {
jvmToolchain(21)
}

Original file line number Diff line number Diff line change
@@ -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<AuthResponse> {
val response = authService.register(request)
return ResponseEntity.status(HttpStatus.CREATED).body(response)
}

@PostMapping("/login")
fun login (@Valid @RequestBody request: LoginRequest): ResponseEntity<AuthResponse> {
val response = authService.login(request)
return ResponseEntity.ok(response)
}

@PostMapping("/refresh")
fun refresh (@Valid @RequestBody request: RefreshTokenRequest): ResponseEntity<AuthResponse> {
val response = authService.refreshToken(request)
return ResponseEntity.ok(response)
}

@PostMapping("/logout")
fun logout(@Valid @RequestBody request: RefreshTokenRequest): ResponseEntity<Map<String, String>> {
authService.logout(request)
return ResponseEntity.ok(mapOf("message" to "Logged out successfully"))
}

}
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.spendoo.identity.dto.response

data class AuthResponse(
val accessToken: String,
val refreshToken: String
)
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
@@ -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
)
41 changes: 41 additions & 0 deletions identity/src/main/kotlin/org/spendoo/identity/entity/User.kt
Original file line number Diff line number Diff line change
@@ -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<EmailVerification> = emptyList(),

@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true)
val refreshTokens: List<RefreshToken> = emptyList()
)
6 changes: 6 additions & 0 deletions identity/src/main/kotlin/org/spendoo/identity/enums/Gender.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.spendoo.identity.enums

enum class Gender {
MALE,
FEMALE
}
Original file line number Diff line number Diff line change
@@ -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)

Loading