From 31b01646821c5105482d11e5b14dfacc4fbc6a99 Mon Sep 17 00:00:00 2001 From: AddictionGames Date: Wed, 7 Jan 2026 17:16:02 +0200 Subject: [PATCH] changed the auth approach to session id based --- .../com/honey/honey/config/WebConfig.java | 6 +- .../honey/controller/AuthController.java | 116 +++++++++++++++ .../honey/honey/dto/CreateSessionRequest.java | 9 ++ .../honey/dto/CreateSessionResponse.java | 16 +++ .../java/com/honey/honey/model/Session.java | 38 +++++ .../honey/repository/SessionRepository.java | 25 ++++ .../honey/honey/security/AuthInterceptor.java | 96 ++++--------- .../honey/honey/service/SessionService.java | 133 ++++++++++++++++++ .../migration/V2__create_sessions_table.sql | 13 ++ 9 files changed, 385 insertions(+), 67 deletions(-) create mode 100644 src/main/java/com/honey/honey/controller/AuthController.java create mode 100644 src/main/java/com/honey/honey/dto/CreateSessionRequest.java create mode 100644 src/main/java/com/honey/honey/dto/CreateSessionResponse.java create mode 100644 src/main/java/com/honey/honey/model/Session.java create mode 100644 src/main/java/com/honey/honey/repository/SessionRepository.java create mode 100644 src/main/java/com/honey/honey/service/SessionService.java create mode 100644 src/main/resources/db/migration/V2__create_sessions_table.sql diff --git a/src/main/java/com/honey/honey/config/WebConfig.java b/src/main/java/com/honey/honey/config/WebConfig.java index 8852ae9..4cb9441 100644 --- a/src/main/java/com/honey/honey/config/WebConfig.java +++ b/src/main/java/com/honey/honey/config/WebConfig.java @@ -14,7 +14,11 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(org.springframework.web.servlet.config.annotation.InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) - .excludePathPatterns("/ping", "/actuator/**"); // ping and actuator don't require auth + .excludePathPatterns( + "/ping", + "/actuator/**", + "/api/auth/tma/session" // Session creation endpoint doesn't require auth + ); } } diff --git a/src/main/java/com/honey/honey/controller/AuthController.java b/src/main/java/com/honey/honey/controller/AuthController.java new file mode 100644 index 0000000..cfe1e2a --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AuthController.java @@ -0,0 +1,116 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.CreateSessionRequest; +import com.honey.honey.dto.CreateSessionResponse; +import com.honey.honey.model.User; +import com.honey.honey.repository.UserRepository; +import com.honey.honey.service.SessionService; +import com.honey.honey.service.TelegramAuthService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final TelegramAuthService telegramAuthService; + private final SessionService sessionService; + private final UserRepository userRepository; + + /** + * Creates a session by validating Telegram initData. + * This is the only endpoint that accepts initData. + */ + @PostMapping("/tma/session") + public CreateSessionResponse createSession(@RequestBody CreateSessionRequest request) { + String initData = request.getInitData(); + + if (initData == null || initData.isBlank()) { + throw new IllegalArgumentException("initData is required"); + } + + // Validate Telegram initData signature + Map tgUser = telegramAuthService.validateAndParseInitData(initData); + + Long telegramId = ((Number) tgUser.get("id")).longValue(); + String username = (String) tgUser.get("username"); + + // Get or create user + User user = getOrCreateUser(telegramId, username); + + // Create session + String sessionId = sessionService.createSession(user); + + log.info("Session created for userId={}, telegramId={}", user.getId(), telegramId); + + return CreateSessionResponse.builder() + .access_token(sessionId) + .expires_in(sessionService.getSessionTtlSeconds()) + .build(); + } + + /** + * Logs out by invalidating the session. + * This endpoint requires authentication (Bearer token). + */ + @PostMapping("/logout") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void logout(@RequestHeader(value = "Authorization", required = false) String authHeader) { + if (authHeader == null) { + log.warn("Logout called without Authorization header"); + return; + } + + String sessionId = extractBearerToken(authHeader); + if (sessionId != null) { + sessionService.invalidateSession(sessionId); + log.info("Session invalidated via logout"); + } + } + + /** + * Gets existing user or creates a new one. + */ + private User getOrCreateUser(Long telegramId, String username) { + var existingUser = userRepository.findByTelegramId(telegramId); + + if (existingUser.isPresent()) { + User user = existingUser.get(); + // Update username if it changed + if (username != null && !username.equals(user.getUsername())) { + user.setUsername(username); + userRepository.save(user); + } + return user; + } + + // Create new user + log.info("Creating new user for telegramId={}, username={}", telegramId, username); + return userRepository.save( + User.builder() + .telegramId(telegramId) + .username(username) + .createdAt(LocalDateTime.now()) + .build() + ); + } + + /** + * Extracts Bearer token from Authorization header. + */ + private String extractBearerToken(String authHeader) { + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return null; + } +} + diff --git a/src/main/java/com/honey/honey/dto/CreateSessionRequest.java b/src/main/java/com/honey/honey/dto/CreateSessionRequest.java new file mode 100644 index 0000000..a1d0bbb --- /dev/null +++ b/src/main/java/com/honey/honey/dto/CreateSessionRequest.java @@ -0,0 +1,9 @@ +package com.honey.honey.dto; + +import lombok.Data; + +@Data +public class CreateSessionRequest { + private String initData; +} + diff --git a/src/main/java/com/honey/honey/dto/CreateSessionResponse.java b/src/main/java/com/honey/honey/dto/CreateSessionResponse.java new file mode 100644 index 0000000..7f08372 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/CreateSessionResponse.java @@ -0,0 +1,16 @@ +package com.honey.honey.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateSessionResponse { + private String access_token; + private Integer expires_in; +} + diff --git a/src/main/java/com/honey/honey/model/Session.java b/src/main/java/com/honey/honey/model/Session.java new file mode 100644 index 0000000..705bc97 --- /dev/null +++ b/src/main/java/com/honey/honey/model/Session.java @@ -0,0 +1,38 @@ +package com.honey.honey.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "sessions") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Session { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "session_id_hash", unique = true, nullable = false, length = 255) + private String sessionIdHash; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } +} + diff --git a/src/main/java/com/honey/honey/repository/SessionRepository.java b/src/main/java/com/honey/honey/repository/SessionRepository.java new file mode 100644 index 0000000..34e72d4 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/SessionRepository.java @@ -0,0 +1,25 @@ +package com.honey.honey.repository; + +import com.honey.honey.model.Session; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface SessionRepository extends JpaRepository { + Optional findBySessionIdHash(String sessionIdHash); + + @Modifying + @Query("DELETE FROM Session s WHERE s.expiresAt < :now") + void deleteExpiredSessions(@Param("now") LocalDateTime now); + + @Modifying + @Query("DELETE FROM Session s WHERE s.sessionIdHash = :sessionIdHash") + void deleteBySessionIdHash(@Param("sessionIdHash") String sessionIdHash); +} + diff --git a/src/main/java/com/honey/honey/security/AuthInterceptor.java b/src/main/java/com/honey/honey/security/AuthInterceptor.java index 3e820c2..13c4e86 100644 --- a/src/main/java/com/honey/honey/security/AuthInterceptor.java +++ b/src/main/java/com/honey/honey/security/AuthInterceptor.java @@ -1,28 +1,22 @@ package com.honey.honey.security; import com.honey.honey.model.User; -import com.honey.honey.repository.UserRepository; -import com.honey.honey.service.TelegramAuthService; +import com.honey.honey.service.SessionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; +import java.util.Optional; @Slf4j @Component @RequiredArgsConstructor public class AuthInterceptor implements HandlerInterceptor { - private final TelegramAuthService telegramAuthService; - private final UserRepository userRepository; + private final SessionService sessionService; @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { @@ -32,82 +26,52 @@ public class AuthInterceptor implements HandlerInterceptor { return true; } - // Get initData from Authorization header (format: "tma ") + // Get Bearer token from Authorization header String authHeader = req.getHeader("Authorization"); - String initData = null; + String sessionId = extractBearerToken(authHeader); - if (authHeader != null && authHeader.startsWith("tma ")) { - initData = authHeader.substring(4); // Remove "tma " prefix - } - - // If no initData, fail - if (initData == null || initData.isBlank()) { - log.error("❌ Missing Telegram initData in Authorization header (expected format: 'tma ')"); + // If no Bearer token, fail + if (sessionId == null || sessionId.isBlank()) { + log.warn("❌ Missing Bearer token in Authorization header"); res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } - // Validate Telegram digital signature - Map tgUser = telegramAuthService.validateAndParseInitData(initData); + // Validate session and get user + Optional userOpt = sessionService.getUserBySession(sessionId); - Long telegramId = ((Number) tgUser.get("id")).longValue(); - String username = (String) tgUser.get("username"); - - // Get or create user (with duplicate handling) - User user = getOrCreateUser(telegramId, username); + if (userOpt.isEmpty()) { + log.warn("❌ Invalid or expired session: {}", maskSessionId(sessionId)); + res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } // Put user in context + User user = userOpt.get(); UserContext.set(user); - log.debug("🔑 Authenticated userId={}, telegramId={}", user.getId(), telegramId); + log.debug("🔑 Authenticated userId={} via session", user.getId()); return true; } /** - * Gets existing user or creates a new one. Handles race conditions and duplicate users gracefully. + * Extracts Bearer token from Authorization header. */ - private synchronized User getOrCreateUser(Long telegramId, String username) { - // Try to find existing user(s) - List existingUsers = userRepository.findAllByTelegramId(telegramId); - - if (!existingUsers.isEmpty()) { - User user = existingUsers.get(0); - - // If multiple users exist, log a warning - if (existingUsers.size() > 1) { - log.warn("⚠️ Found {} duplicate users for telegramId={}. Using the first one (id={}). " + - "Consider cleaning up duplicates.", existingUsers.size(), telegramId, user.getId()); - } - - // Update username if it changed - if (username != null && !username.equals(user.getUsername())) { - user.setUsername(username); - userRepository.save(user); - } - - return user; + private String extractBearerToken(String authHeader) { + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7).trim(); } + return null; + } - // No user found, create new one - try { - log.info("🆕 Creating new user for telegramId={}, username={}", telegramId, username); - return userRepository.save( - User.builder() - .telegramId(telegramId) - .username(username) - .createdAt(LocalDateTime.now()) - .build() - ); - } catch (DataIntegrityViolationException e) { - // Another thread created the user, fetch it - log.debug("User already exists (created by another thread), fetching..."); - List users = userRepository.findAllByTelegramId(telegramId); - if (users.isEmpty()) { - log.error("Failed to create user and couldn't find it after duplicate key error"); - throw new RuntimeException("Failed to get or create user", e); - } - return users.get(0); + /** + * Masks session ID for logging (security). + */ + private String maskSessionId(String sessionId) { + if (sessionId == null || sessionId.length() < 8) { + return "***"; } + return sessionId.substring(0, 4) + "***" + sessionId.substring(sessionId.length() - 4); } @Override diff --git a/src/main/java/com/honey/honey/service/SessionService.java b/src/main/java/com/honey/honey/service/SessionService.java new file mode 100644 index 0000000..67621dd --- /dev/null +++ b/src/main/java/com/honey/honey/service/SessionService.java @@ -0,0 +1,133 @@ +package com.honey.honey.service; + +import com.honey.honey.model.Session; +import com.honey.honey.model.User; +import com.honey.honey.repository.SessionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SessionService { + + private final SessionRepository sessionRepository; + private static final int SESSION_TTL_HOURS = 24; // 1 day + private static final SecureRandom secureRandom = new SecureRandom(); + + /** + * Creates a new session for a user. + * Returns the raw session ID (to be sent to frontend) and stores the hash in DB. + */ + @Transactional + public String createSession(User user) { + // Generate cryptographically random session ID + byte[] randomBytes = new byte[32]; + secureRandom.nextBytes(randomBytes); + String sessionId = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + + // Hash the session ID for storage + String sessionIdHash = hashSessionId(sessionId); + + // Calculate expiration + LocalDateTime expiresAt = LocalDateTime.now().plusHours(SESSION_TTL_HOURS); + + // Create and save session + Session session = Session.builder() + .sessionIdHash(sessionIdHash) + .user(user) + .createdAt(LocalDateTime.now()) + .expiresAt(expiresAt) + .build(); + + sessionRepository.save(session); + + log.info("Created session for userId={}, expiresAt={}", user.getId(), expiresAt); + return sessionId; + } + + /** + * Validates a session ID and returns the associated user. + * Returns empty if session is invalid or expired. + */ + @Transactional(readOnly = true) + public Optional getUserBySession(String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + return Optional.empty(); + } + + String sessionIdHash = hashSessionId(sessionId); + Optional sessionOpt = sessionRepository.findBySessionIdHash(sessionIdHash); + + if (sessionOpt.isEmpty()) { + log.debug("Session not found: {}", maskSessionId(sessionId)); + return Optional.empty(); + } + + Session session = sessionOpt.get(); + + if (session.isExpired()) { + log.debug("Session expired: {}", maskSessionId(sessionId)); + // Optionally delete expired session + sessionRepository.delete(session); + return Optional.empty(); + } + + return Optional.of(session.getUser()); + } + + /** + * Invalidates a session (logout). + */ + @Transactional + public void invalidateSession(String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + return; + } + + String sessionIdHash = hashSessionId(sessionId); + sessionRepository.deleteBySessionIdHash(sessionIdHash); + log.info("Invalidated session: {}", maskSessionId(sessionId)); + } + + /** + * Hashes a session ID using SHA-256. + */ + private String hashSessionId(String sessionId) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(sessionId.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (Exception e) { + log.error("Failed to hash session ID", e); + throw new RuntimeException("Failed to hash session ID", e); + } + } + + /** + * Masks session ID for logging (security). + */ + private String maskSessionId(String sessionId) { + if (sessionId == null || sessionId.length() < 8) { + return "***"; + } + return sessionId.substring(0, 4) + "***" + sessionId.substring(sessionId.length() - 4); + } + + /** + * Gets session TTL in seconds. + */ + public int getSessionTtlSeconds() { + return SESSION_TTL_HOURS * 3600; + } +} + diff --git a/src/main/resources/db/migration/V2__create_sessions_table.sql b/src/main/resources/db/migration/V2__create_sessions_table.sql new file mode 100644 index 0000000..8c185b0 --- /dev/null +++ b/src/main/resources/db/migration/V2__create_sessions_table.sql @@ -0,0 +1,13 @@ +-- Create sessions table for Bearer token authentication +CREATE TABLE IF NOT EXISTS sessions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + session_id_hash VARCHAR(255) NOT NULL UNIQUE, + user_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + INDEX idx_session_hash (session_id_hash), + INDEX idx_expires_at (expires_at), + INDEX idx_user_id (user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +