Skip to content
Merged
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
85 changes: 57 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Secure Authentication Service

**Spring Boot · JWT · TOTP · PostgreSQL · Docker · Thymeleaf**
**Spring Boot · JWT · TOTP · PostgreSQL · Redis · Docker · Thymeleaf**

## Overview

Expand Down Expand Up @@ -29,6 +29,11 @@ It provides:
* Compatible with Google Authenticator and similar apps
* One-time codes validated server-side

* **Redis Integration**

* Stores temporary TOTP challenge IDs and secrets
* TTL-based automatic expiration

* **PostgreSQL**

* Supports PostgreSQL **version 15 or earlier**
Expand All @@ -53,24 +58,26 @@ It provides:

## Technology Stack

| Layer | Technology |
| ----------------- | -------------------------------------- |
| Backend | Spring Boot |
| Security | Spring Security, JWT |
| 2FA | TOTP (Google Authenticator compatible) |
| Database | PostgreSQL (≤ 15) |
| UI | Thymeleaf |
| API Specification | OpenAPI 3 |
| Build Tool | Gradle |
| Containerization | Docker, Docker Compose |
| Layer | Technology |
| ----------------------- | -------------------------------------- |
| Backend | Spring Boot |
| Security | Spring Security, JWT |
| 2FA | TOTP (Google Authenticator compatible) |
| Database | PostgreSQL (≤ 15) |
| Cache/Temporary Storage | Redis |
| UI | Thymeleaf |
| API Specification | OpenAPI 3 |
| Build Tool | Gradle |
| Containerization | Docker, Docker Compose |

---

## Authentication Flow (High Level)

1. User logs in with username/password
1. User logs in with email/password
2. If TOTP is enabled:

* Server creates a temporary `challengeId` stored in Redis
* User must provide a valid one-time code
3. On success:

Expand All @@ -81,35 +88,55 @@ It provides:

## TOTP Enrollment Flow

1. User requests TOTP enrollment
1. User requests TOTP enrollment via `/qrcode`
2. Server generates:

* Shared secret
* A shared secret
* QR code
* Challenge ID stored in Redis with TTL
3. User scans QR code using Google Authenticator
4. User submits a generated TOTP code for verification
4. User submits a generated TOTP code via `/validateOTP` for verification
5. TOTP is activated for the account

---

## Sequence Diagram (TOTP Authentication)
## Sequence Diagram (TOTP Authentication with Redis)

```mermaid
sequenceDiagram
participant User
participant UI (Thymeleaf)
participant Auth API
participant Redis
participant Database

User->>UI (Thymeleaf): Login (username/password)
UI->>Auth API: POST /auth/login
Auth API->>Database: Validate credentials
Auth API-->>UI (Thymeleaf): TOTP required
User->>UI: Register (/register)
UI->>Auth API: POST /register
Auth API->>Database: Create user
Auth API-->>UI: Registration success

User->>UI (Thymeleaf): Enter TOTP code
UI->>Auth API: POST /auth/totp/verify
Auth API->>Database: Validate TOTP secret
Auth API-->>UI (Thymeleaf): JWT issued
User->>UI: Request QR code (/qrcode)
UI->>Auth API: GET /qrcode
Auth API->>Redis: Store challengeId + secret
Auth API-->>UI: Return QR code + challengeId
User->>Google Authenticator: Scan QR code

User->>UI: Login (/login)
UI->>Auth API: POST /login (email + password)
Auth API->>Database: Validate credentials
Auth API->>Redis: Create challengeId for TOTP
Auth API-->>UI: TOTP required + challengeId

User->>UI: Enter OTP (/validateOTP)
UI->>Auth API: POST /validateOTP (challengeId + OTP)
Auth API->>Redis: Retrieve secret
Auth API->>Redis: Delete challengeId if valid
Auth API-->>UI: JWT issued

User->>UI: Access profile (/profile)
UI->>Auth API: JWT in Authorization header
Auth API->>Database: Validate JWT
Auth API-->>UI: Return profile data
```

---
Expand All @@ -132,7 +159,7 @@ sequenceDiagram
cp env.template .env
```

2. Update values as needed (database credentials, JWT secrets, etc.).
2. Update values as needed (database credentials, Redis credentials, JWT secrets, etc.).

> **Note:** All sensitive configuration is managed via environment variables.

Expand All @@ -147,6 +174,7 @@ docker compose up -d
This will start:

* PostgreSQL
* Redis
* The Spring Boot application

---
Expand Down Expand Up @@ -197,8 +225,9 @@ This ensures:

* JWT validation
* TOTP enrollment and verification
* Redis challenge handling
* Authentication edge cases (expired tokens, invalid codes)
* Consider Testcontainers for PostgreSQL integration tests
* Consider Testcontainers for PostgreSQL and Redis integration tests

---

Expand All @@ -225,6 +254,6 @@ This ensures:
## Security Notes

* JWT secrets must be strong and never committed
* TOTP secrets are stored securely and never exposed
* TOTP secrets are stored securely and only temporarily in Redis during validation
* HTTPS is strongly recommended for production
* Consider rate-limiting authentication endpoints
* Consider rate-limiting authentication endpoints
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ dependencies {
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.apache.commons:commons-pool2'
implementation 'commons-codec:commons-codec:1.16.0'
implementation 'com.google.zxing:core:3.5.2'
implementation 'com.google.zxing:javase:3.5.2'
Expand Down
10 changes: 9 additions & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,24 @@ services:
- postgres-data:/var/lib/postgresql/data
restart: always

redis:
image: redis:7
container_name: auth-redis
ports:
- "6379:6379"
restart: always

spring-app:
build: .
container_name: auth-api
depends_on:
- db
- redis
ports:
- "8080:8080"
env_file:
- .env
restart: always

volumes:
postgres-data:
postgres-data:
4 changes: 3 additions & 1 deletion env.template
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ SPRING_DATASOURCE_PASSWORD: mypassword
SPRING_JPA_HIBERNATE_DDL_AUTO: none
SPRING_PROFILES_ACTIVE: test
CRYPTO_SALT: a1b2c3d4e5f67890abcdef1234567890
CRYPTO_SECRET_KEY: test
CRYPTO_SECRET_KEY: test
SPRING_DATA_REDIS_HOST=redis
SPRING_DATA_REDIS_PORT=6379
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ public String index(Model model) {
}

@PostMapping("/login")
public String login(@ModelAttribute AuthUser toAuthenticate, HttpServletResponse response) {
var user = service.authenticateUser(toAuthenticate);
var cookie = putTokenInCookie(user.getAccessToken());
response.addCookie(cookie);

return "redirect:/validateOTP";
public String login(@ModelAttribute AuthUser toAuthenticate) {
try {
var challengeId = service.authenticateUser(toAuthenticate).getChallengeId();
return "redirect:/validateOTP?challengeId=" + challengeId;
} catch (Exception e) {
return "redirect:/";
}
}

@PostMapping("/logout")
Expand All @@ -53,7 +54,8 @@ public String registerPage(Model model) {
}

@GetMapping("/validateOTP")
public String validateOTP() {
public String validateOTP(@RequestParam("challengeId") String challengeId, Model model) {
model.addAttribute("challengeId", challengeId);
return "otp";
}

Expand All @@ -72,9 +74,14 @@ public String profile(Model model) {
}

@PostMapping("/validateOTP")
public String validate(@RequestParam("otp") String otp) {
public String validate(
@RequestParam("challengeId") String challengeId,
@RequestParam("otp") String otp,
HttpServletResponse response) {
try {
service.validateOTP(otp);
var user = service.validateOTP(challengeId, otp);
var cookie = putTokenInCookie(user.getAccessToken());
response.addCookie(cookie);
return "redirect:/profile";
} catch (Exception e) {
return "redirect:/validateOTP";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ public SecurityFilterChain configure(HttpSecurity http) throws Exception {
.requestMatchers(POST, "/register")
.permitAll()
.requestMatchers(GET, "/validateOTP")
.authenticated()
.permitAll()
.requestMatchers(POST, "/validateOTP")
.authenticated()
.permitAll()
.requestMatchers(GET, "/qrcode")
.permitAll()
.requestMatchers(GET, "/profile")
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/app/esiroi/auth/model/dto/MfaChallenge.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package app.esiroi.auth.model.dto;

import java.io.Serializable;
import java.time.Instant;
import lombok.Data;

@Data
public class MfaChallenge implements Serializable {
private String challengeId;
private String userId;
private int attempts;
private Instant createdAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package app.esiroi.auth.repository.configuration;

import app.esiroi.auth.model.dto.MfaChallenge;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConf {

@Bean
public RedisTemplate<String, MfaChallenge> template(
RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, MfaChallenge> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);

Jackson2JsonRedisSerializer<MfaChallenge> serializer =
new Jackson2JsonRedisSerializer<>(MfaChallenge.class);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);

return template;
}
}
28 changes: 20 additions & 8 deletions src/main/java/app/esiroi/auth/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package app.esiroi.auth.service;

import app.esiroi.auth.endpoint.rest.model.AuthUser;
import app.esiroi.auth.endpoint.security.AuthProvider;
import app.esiroi.auth.endpoint.security.Encryptor;
import app.esiroi.auth.endpoint.security.JWTConf;
import app.esiroi.auth.endpoint.security.TOTPConf;
import app.esiroi.auth.model.User;
import app.esiroi.auth.model.dto.MfaChallenge;
import app.esiroi.auth.model.exception.ForbiddenException;
import app.esiroi.auth.model.exception.NotFoundException;
import app.esiroi.auth.repository.UserRepository;
Expand All @@ -30,18 +30,25 @@ public class AuthService {
private final JWTConf jwtConf;
private final TOTPConf totpConf;
private final Encryptor encryptor;
private final MfaChallengeService mfaService;

public User getUserByEmail(String email) {
return repository
.findByEmail(email)
.orElseThrow(() -> new NotFoundException("User.email=" + email + " not found"));
}

public User authenticateUser(AuthUser toAuthenticate) {
public User getUserById(String id) {
return repository
.findById(id)
.orElseThrow(() -> new NotFoundException("User.id=" + id + " not found"));
}

public MfaChallenge authenticateUser(AuthUser toAuthenticate) {
var user = getUserByEmail(toAuthenticate.getEmail());
if (encoder.matches(toAuthenticate.getPassword(), user.getPasswordHash())) {
user.setAccessToken(jwtConf.generateToken(user.getEmail()));
return user;
var userId = user.getId();
return mfaService.create(userId);
}
throw new ForbiddenException("Bad credentials");
}
Expand All @@ -50,17 +57,22 @@ public User saveUser(User user) {
return repository.save(user);
}

public User validateOTP(String otp) {
var email = AuthProvider.getAuthenticatedUserEmail();
var authUser = getUserByEmail(email);
public User validateOTP(String challengeId, String otp) {
var mfa =
mfaService
.get(challengeId)
.orElseThrow(() -> new NotFoundException("MFA challenge not found"));
var authUser = getUserById(mfa.getUserId());

var encryptedSecret = authUser.getOtpSecret();
var decryptedSecret = new String(encryptor.getInstance().decrypt(encryptedSecret));

var isOTPValid = totpConf.validateTOTP(decryptedSecret, otp);
if (isOTPValid) {
authUser.setOtpValidationRequired(false);
return repository.save(authUser);
var saved = repository.save(authUser);
saved.setAccessToken(jwtConf.generateToken(authUser.getEmail()));
return saved;
}
throw new ForbiddenException("OTP Invalid");
}
Expand Down
Loading