From c8d3ebd532eb9f62f5fe36f740a0d9ad93957dc3 Mon Sep 17 00:00:00 2001 From: Ny Hasina Vagno Date: Mon, 22 Dec 2025 12:58:49 +0400 Subject: [PATCH 1/4] chore: configure redis for mfa challenge --- build.gradle | 2 + .../esiroi/auth/model/dto/MfaChallenge.java | 13 ++++++ .../repository/configuration/RedisConf.java | 27 ++++++++++++ .../auth/service/MfaChallengeService.java | 42 +++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 src/main/java/app/esiroi/auth/model/dto/MfaChallenge.java create mode 100644 src/main/java/app/esiroi/auth/repository/configuration/RedisConf.java create mode 100644 src/main/java/app/esiroi/auth/service/MfaChallengeService.java diff --git a/build.gradle b/build.gradle index 8ae4712..eec9a24 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/app/esiroi/auth/model/dto/MfaChallenge.java b/src/main/java/app/esiroi/auth/model/dto/MfaChallenge.java new file mode 100644 index 0000000..0fc59ef --- /dev/null +++ b/src/main/java/app/esiroi/auth/model/dto/MfaChallenge.java @@ -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; +} diff --git a/src/main/java/app/esiroi/auth/repository/configuration/RedisConf.java b/src/main/java/app/esiroi/auth/repository/configuration/RedisConf.java new file mode 100644 index 0000000..22d8d36 --- /dev/null +++ b/src/main/java/app/esiroi/auth/repository/configuration/RedisConf.java @@ -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 template( + RedisConnectionFactory redisConnectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory); + + Jackson2JsonRedisSerializer serializer = + new Jackson2JsonRedisSerializer<>(MfaChallenge.class); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(serializer); + + return template; + } +} diff --git a/src/main/java/app/esiroi/auth/service/MfaChallengeService.java b/src/main/java/app/esiroi/auth/service/MfaChallengeService.java new file mode 100644 index 0000000..e2f14ab --- /dev/null +++ b/src/main/java/app/esiroi/auth/service/MfaChallengeService.java @@ -0,0 +1,42 @@ +package app.esiroi.auth.service; + +import static java.time.Instant.now; +import static java.util.UUID.randomUUID; + +import app.esiroi.auth.model.dto.MfaChallenge; +import java.time.Duration; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class MfaChallengeService { + private static final Duration TTL = Duration.ofMinutes(2); + private final RedisTemplate redisTemplate; + + private String key(String id) { + return "mfa:challenge:" + id; + } + + public MfaChallenge create(String userId) { + MfaChallenge c = new MfaChallenge(); + c.setChallengeId(randomUUID().toString()); + c.setUserId(userId); + c.setAttempts(0); + c.setCreatedAt(now()); + + redisTemplate.opsForValue().set(key(c.getChallengeId()), c, TTL); + + return c; + } + + public Optional get(String id) { + return Optional.ofNullable(redisTemplate.opsForValue().get(key(id))); + } + + public void delete(String id) { + redisTemplate.delete(key(id)); + } +} From cde663b69301b2dfca988e5e3d3bc605fddce2a3 Mon Sep 17 00:00:00 2001 From: Ny Hasina Vagno Date: Mon, 22 Dec 2025 14:03:56 +0400 Subject: [PATCH 2/4] chore: integrate redis to store mfa challenge id --- .../controller/AuthenticationController.java | 25 +++++++++++------ .../auth/endpoint/security/SecurityConf.java | 4 +-- .../app/esiroi/auth/service/AuthService.java | 28 +++++++++++++------ .../auth/service/MfaChallengeService.java | 3 +- src/main/resources/templates/otp.html | 1 + 5 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/main/java/app/esiroi/auth/endpoint/controller/AuthenticationController.java b/src/main/java/app/esiroi/auth/endpoint/controller/AuthenticationController.java index 8b415d6..8afbd08 100644 --- a/src/main/java/app/esiroi/auth/endpoint/controller/AuthenticationController.java +++ b/src/main/java/app/esiroi/auth/endpoint/controller/AuthenticationController.java @@ -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") @@ -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"; } @@ -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"; diff --git a/src/main/java/app/esiroi/auth/endpoint/security/SecurityConf.java b/src/main/java/app/esiroi/auth/endpoint/security/SecurityConf.java index ae7ec71..3c36fe5 100644 --- a/src/main/java/app/esiroi/auth/endpoint/security/SecurityConf.java +++ b/src/main/java/app/esiroi/auth/endpoint/security/SecurityConf.java @@ -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") diff --git a/src/main/java/app/esiroi/auth/service/AuthService.java b/src/main/java/app/esiroi/auth/service/AuthService.java index 091bb21..330eaa5 100644 --- a/src/main/java/app/esiroi/auth/service/AuthService.java +++ b/src/main/java/app/esiroi/auth/service/AuthService.java @@ -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; @@ -30,6 +30,7 @@ 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 @@ -37,11 +38,17 @@ public User getUserByEmail(String 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"); } @@ -50,9 +57,12 @@ 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)); @@ -60,7 +70,9 @@ public User validateOTP(String otp) { 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"); } diff --git a/src/main/java/app/esiroi/auth/service/MfaChallengeService.java b/src/main/java/app/esiroi/auth/service/MfaChallengeService.java index e2f14ab..24e6e02 100644 --- a/src/main/java/app/esiroi/auth/service/MfaChallengeService.java +++ b/src/main/java/app/esiroi/auth/service/MfaChallengeService.java @@ -1,6 +1,5 @@ package app.esiroi.auth.service; -import static java.time.Instant.now; import static java.util.UUID.randomUUID; import app.esiroi.auth.model.dto.MfaChallenge; @@ -25,7 +24,7 @@ public MfaChallenge create(String userId) { c.setChallengeId(randomUUID().toString()); c.setUserId(userId); c.setAttempts(0); - c.setCreatedAt(now()); + // c.setCreatedAt(now()); Not supported for now redisTemplate.opsForValue().set(key(c.getChallengeId()), c, TTL); diff --git a/src/main/resources/templates/otp.html b/src/main/resources/templates/otp.html index 146c66d..efd4d95 100644 --- a/src/main/resources/templates/otp.html +++ b/src/main/resources/templates/otp.html @@ -10,6 +10,7 @@

Validate OTP

+ From 45c57bbb3bab2f3d9f468bc045a38e248e5b3b39 Mon Sep 17 00:00:00 2001 From: Ny Hasina Vagno Date: Mon, 22 Dec 2025 14:04:30 +0400 Subject: [PATCH 3/4] chore: update docker comopose file by adding redis --- compose.yaml | 10 +++++++++- env.template | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/compose.yaml b/compose.yaml index 077ae33..a91f4bd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,11 +10,19 @@ 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: @@ -22,4 +30,4 @@ services: restart: always volumes: - postgres-data: \ No newline at end of file + postgres-data: diff --git a/env.template b/env.template index eb65cc3..abbbdd5 100644 --- a/env.template +++ b/env.template @@ -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 \ No newline at end of file +CRYPTO_SECRET_KEY: test +SPRING_DATA_REDIS_HOST=redis +SPRING_DATA_REDIS_PORT=6379 \ No newline at end of file From 0e52e1c04aa9be61141edd1b455a584316fd0af8 Mon Sep 17 00:00:00 2001 From: Ny Hasina Vagno Date: Mon, 22 Dec 2025 14:13:56 +0400 Subject: [PATCH 4/4] chore: update readme --- README.md | 85 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 71dab11..03c4640 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Secure Authentication Service -**Spring Boot · JWT · TOTP · PostgreSQL · Docker · Thymeleaf** +**Spring Boot · JWT · TOTP · PostgreSQL · Redis · Docker · Thymeleaf** ## Overview @@ -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** @@ -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: @@ -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 ``` --- @@ -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. @@ -147,6 +174,7 @@ docker compose up -d This will start: * PostgreSQL +* Redis * The Spring Boot application --- @@ -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 --- @@ -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 \ No newline at end of file