Skip to content
Open
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
253 changes: 253 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
# Spring Boot Crash Course - Notes Application

A full-stack notes management application built with Kotlin, Spring Boot, MongoDB, and JWT authentication. Features both a RESTful API and a server-side rendered web interface (MPA).

## Features

### Core Functionality
- 📝 **CRUD Operations**: Create, Read, Update, and Delete notes
- 🔐 **JWT Authentication**: Secure token-based authentication with access and refresh tokens
- 🎨 **Colorful Notes**: 5 predefined color options for better organization
- 👤 **User Management**: Secure registration and login system
- 🔄 **Dual Interface**: RESTful API for programmatic access and Web UI for browser access

### Technical Features
- **Backend**: Kotlin + Spring Boot 3.4.3
- **Database**: MongoDB with Spring Data
- **Security**: JWT tokens with 15-minute access tokens and 30-day refresh tokens
- **View Layer**: Thymeleaf templates for server-side rendering
- **Cookie Management**: HttpOnly cookies for secure token storage in web interface
- **Validation**: Jakarta Bean Validation with custom error handling

## Architecture

### REST API Endpoints
- `POST /auth/register` - Register a new user
- `POST /auth/login` - Login and receive JWT tokens
- `POST /auth/refresh` - Refresh access token
- `GET /notes` - Get all notes for authenticated user
- `POST /notes` - Create or update a note
- `DELETE /notes/{id}` - Delete a note

### Web Interface Routes
- `GET /web/home` - Landing page
- `GET /web/register` - Registration page
- `POST /web/register` - Process registration
- `GET /web/login` - Login page
- `POST /web/login` - Process login (sets JWT cookies)
- `GET /web/notes` - Notes management page (protected)
- `POST /web/notes` - Create/update note via form
- `POST /web/notes/{id}/delete` - Delete note
- `POST /web/logout` - Logout (clears cookies)

## Getting Started

### Prerequisites
- Java 17 or higher
- MongoDB instance (local or Atlas)
- Gradle (wrapper included)

### Environment Variables
```bash
MONGODB_CONNECTION_STRING=mongodb://localhost:27017/spring_crash_course
JWT_SECRET_BASE64=<your-base64-encoded-secret>
```

### Running the Application

1. **Clone the repository**
```bash
git clone https://github.com/sebaslagu/SpringBootCrashCourse.git
cd SpringBootCrashCourse
```

2. **Set environment variables**
```bash
export MONGODB_CONNECTION_STRING="mongodb://localhost:27017/spring_crash_course"
export JWT_SECRET_BASE64="<your-secret>"
```

3. **Run with Gradle**
```bash
./gradlew bootRun
```

4. **Access the application**
- Web Interface: http://localhost:8085/web/home
- API: http://localhost:8085/

### Docker Setup (MongoDB)
```bash
docker run -d --name mongodb-notes -p 27017:27017 mongo:latest
```

## Project Structure

```
src/main/
├── kotlin/com/plcoding/spring_boot_crash_course/
│ ├── controllers/
│ │ ├── AuthController.kt # REST API authentication
│ │ ├── NoteController.kt # REST API notes CRUD
│ │ ├── StatusController.kt # Health check endpoint
│ │ └── web/
│ │ ├── WebHomeController.kt # Web home page
│ │ ├── WebAuthController.kt # Web auth pages
│ │ └── WebNoteController.kt # Web notes pages
│ ├── database/
│ │ ├── model/
│ │ │ ├── User.kt
│ │ │ ├── Note.kt
│ │ │ └── RefreshToken.kt
│ │ └── repository/ # MongoDB repositories
│ └── security/
│ ├── SecurityConfig.kt # Spring Security configuration
│ ├── JwtAuthFilter.kt # JWT validation filter
│ ├── JwtService.kt # JWT token management
│ ├── AuthService.kt # Authentication service
│ └── HashEncoder.kt # Password hashing
└── resources/
├── templates/ # Thymeleaf templates
│ ├── home.html
│ ├── login.html
│ ├── register.html
│ └── notes.html
├── static/
│ ├── css/
│ │ └── styles.css
│ └── js/
│ ├── notes.js
│ └── auth.js
└── application.properties
```

## Security

### Authentication Flow

**API Authentication:**
1. Client calls `/auth/login` with credentials
2. Server returns `accessToken` and `refreshToken` in JSON response
3. Client includes `Authorization: Bearer <accessToken>` header in subsequent requests
4. When access token expires, client calls `/auth/refresh` with refresh token

**Web Authentication:**
1. User submits login form to `/web/login`
2. Server validates credentials and generates tokens
3. Tokens are stored in HttpOnly cookies:
- `access_token`: 15-minute expiry
- `refresh_token`: 30-day expiry
4. Cookies are automatically sent with subsequent requests
5. `JwtAuthFilter` validates tokens from either cookies or Authorization header

### Password Requirements
- Minimum 9 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit

### Security Notes
- **CSRF Protection**: Currently disabled for simplicity. In production, consider enabling CSRF protection for web forms while exempting API endpoints.
- **Cookie Security**: Cookies use `HttpOnly` flag to prevent JavaScript access. In production, also set `Secure` flag to require HTTPS.
- **Token Storage**: Refresh tokens are hashed before storage in MongoDB.
- **Stateless Sessions**: Application uses stateless JWT authentication; no server-side session storage.

## API Usage Examples

### Register a User
```bash
curl -X POST http://localhost:8085/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"Password123"}'
```

### Login
```bash
curl -X POST http://localhost:8085/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"Password123"}'
```

Response:
```json
{
"accessToken": "eyJhbGc...",
"refreshToken": "eyJhbGc..."
}
```

### Create a Note
```bash
curl -X POST http://localhost:8085/notes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your-access-token>" \
-d '{
"title":"Shopping List",
"content":"Buy milk and eggs",
"color":4293848814
}'
```

### Get All Notes
```bash
curl http://localhost:8085/notes \
-H "Authorization: Bearer <your-access-token>"
```

## Development

### Building
```bash
./gradlew build
```

### Running Tests
```bash
./gradlew test
```

### Code Style
The project follows Kotlin coding conventions with Spring Boot best practices.

## Technologies Used

- **Kotlin** 1.9.25
- **Spring Boot** 3.4.3
- **Spring Security** with JWT
- **Spring Data MongoDB**
- **Thymeleaf** template engine
- **MongoDB** for data persistence
- **JJWT** 0.12.6 for JWT handling
- **Gradle** for build management

## Color Options

Notes can be assigned one of five colors:
- 🟡 Peach (`4294951115`)
- 🩷 Pink (`4293848814`)
- 🟢 Green (`4289374890`)
- 🟣 Purple (`4292149695`)
- ⚪ White (`4278255615`)

## Contributing

1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request

## License

This project is open source and available for educational purposes.

## Author

Based on the Spring Boot Crash Course by Philipp Lackner.
MPA layer implementation by GitHub Copilot.

## Acknowledgments

- Spring Boot team for the excellent framework
- MongoDB for the database
- Thymeleaf for the template engine
3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.security:spring-security-crypto")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
Expand All @@ -49,4 +50,4 @@ kotlin {

tasks.withType<Test> {
useJUnitPlatform()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.plcoding.spring_boot_crash_course.controllers.web

import com.plcoding.spring_boot_crash_course.security.AuthService
import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServletResponse
import jakarta.validation.Valid
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.Pattern
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.validation.BindingResult
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.server.ResponseStatusException

@Controller
@RequestMapping("/web")
class WebAuthController(
private val authService: AuthService
) {

data class LoginForm(
@field:Email(message = "Invalid email format.")
val email: String = "",
val password: String = ""
)

data class RegisterForm(
@field:Email(message = "Invalid email format.")
val email: String = "",
@field:Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{9,}\$",
message = "Password must be at least 9 characters long and contain at least one digit, uppercase and lowercase character."
)
val password: String = ""
)

@GetMapping("/login")
fun loginPage(model: Model): String {
model.addAttribute("loginForm", LoginForm())
return "login"
}

@PostMapping("/login")
fun login(
@Valid @ModelAttribute loginForm: LoginForm,
bindingResult: BindingResult,
model: Model,
response: HttpServletResponse
): String {
if (bindingResult.hasErrors()) {
return "login"
}

return try {
val tokenPair = authService.login(loginForm.email, loginForm.password)

// Set access token cookie (15 minutes)
val accessCookie = Cookie("access_token", tokenPair.accessToken)
accessCookie.isHttpOnly = true
accessCookie.path = "/"
accessCookie.maxAge = 15 * 60 // 15 minutes
accessCookie.secure = false // Set to true in production with HTTPS
response.addCookie(accessCookie)

// Set refresh token cookie (30 days)
val refreshCookie = Cookie("refresh_token", tokenPair.refreshToken)
refreshCookie.isHttpOnly = true
refreshCookie.path = "/"
refreshCookie.maxAge = 30 * 24 * 60 * 60 // 30 days
refreshCookie.secure = false // Set to true in production with HTTPS
response.addCookie(refreshCookie)

"redirect:/web/notes"
} catch (e: BadCredentialsException) {
model.addAttribute("error", "Invalid credentials.")
"login"
} catch (e: Exception) {
model.addAttribute("error", "An error occurred during login.")
"login"
}
}

@GetMapping("/register")
fun registerPage(model: Model): String {
model.addAttribute("registerForm", RegisterForm())
return "register"
}

@PostMapping("/register")
fun register(
@Valid @ModelAttribute registerForm: RegisterForm,
bindingResult: BindingResult,
model: Model
): String {
if (bindingResult.hasErrors()) {
return "register"
}

return try {
authService.register(registerForm.email, registerForm.password)
model.addAttribute("success", "Registration successful! Please log in.")
"redirect:/web/login?registered"
} catch (e: ResponseStatusException) {
model.addAttribute("error", e.reason ?: "Registration failed.")
"register"
} catch (e: Exception) {
model.addAttribute("error", "An error occurred during registration.")
"register"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.plcoding.spring_boot_crash_course.controllers.web

import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping

@Controller
class WebHomeController {

@GetMapping("/web/home")
fun home(): String {
return "home"
}
}
Loading