Created base BE. Auth + /current endpoint
This commit is contained in:
18
src/main/java/com/honey/honey/HoneyBackendApplication.java
Normal file
18
src/main/java/com/honey/honey/HoneyBackendApplication.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.honey.honey;
|
||||
|
||||
import com.honey.honey.config.ConfigLoader;
|
||||
import com.honey.honey.config.TelegramProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties({TelegramProperties.class})
|
||||
public class HoneyBackendApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication app = new SpringApplication(HoneyBackendApplication.class);
|
||||
app.addListeners(new ConfigLoader());
|
||||
app.run(args);
|
||||
}
|
||||
}
|
||||
|
||||
82
src/main/java/com/honey/honey/config/ConfigLoader.java
Normal file
82
src/main/java/com/honey/honey/config/ConfigLoader.java
Normal file
@@ -0,0 +1,82 @@
|
||||
package com.honey.honey.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.MutablePropertySources;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Loads configuration from a mounted secret file (tmpfs) with fallback to environment variables.
|
||||
* This allows switching between Railway (env vars) and Inferno (mounted file) deployments.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Mounted file at /run/secrets/honey-config.properties (Inferno)
|
||||
* 2. Environment variables (Railway)
|
||||
*/
|
||||
@Slf4j
|
||||
public class ConfigLoader implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {
|
||||
|
||||
private static final String SECRET_FILE_PATH = "/run/secrets/honey-config.properties";
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
|
||||
ConfigurableEnvironment environment = event.getEnvironment();
|
||||
MutablePropertySources propertySources = environment.getPropertySources();
|
||||
|
||||
Map<String, Object> configProperties = new HashMap<>();
|
||||
|
||||
// Try to load from mounted file first (Inferno deployment)
|
||||
File secretFile = new File(SECRET_FILE_PATH);
|
||||
if (secretFile.exists() && secretFile.isFile() && secretFile.canRead()) {
|
||||
log.info("📁 Loading configuration from mounted secret file: {}", SECRET_FILE_PATH);
|
||||
try {
|
||||
Properties props = new Properties();
|
||||
try (FileInputStream fis = new FileInputStream(secretFile)) {
|
||||
props.load(fis);
|
||||
}
|
||||
|
||||
for (String key : props.stringPropertyNames()) {
|
||||
String value = props.getProperty(key);
|
||||
configProperties.put(key, value);
|
||||
log.debug("Loaded from file: {} = {}", key, maskSensitiveValue(key, value));
|
||||
}
|
||||
log.info("✅ Successfully loaded {} properties from secret file", configProperties.size());
|
||||
} catch (IOException e) {
|
||||
log.warn("⚠️ Failed to read secret file, falling back to environment variables: {}", e.getMessage());
|
||||
}
|
||||
} else {
|
||||
log.info("📝 Secret file not found at {}, using environment variables", SECRET_FILE_PATH);
|
||||
}
|
||||
|
||||
// Environment variables are already loaded by Spring Boot by default
|
||||
// We just add file-based config as a higher priority source if it exists
|
||||
if (!configProperties.isEmpty()) {
|
||||
propertySources.addFirst(new MapPropertySource("secretFileConfig", configProperties));
|
||||
log.info("✅ Configuration loaded: {} properties from file, environment variables as fallback",
|
||||
configProperties.size());
|
||||
} else {
|
||||
log.info("✅ Using environment variables for configuration");
|
||||
}
|
||||
}
|
||||
|
||||
private String maskSensitiveValue(String key, String value) {
|
||||
if (value == null) return "null";
|
||||
if (key.toLowerCase().contains("password") ||
|
||||
key.toLowerCase().contains("token") ||
|
||||
key.toLowerCase().contains("secret") ||
|
||||
key.toLowerCase().contains("key")) {
|
||||
return value.length() > 4 ? value.substring(0, 2) + "***" + value.substring(value.length() - 2) : "***";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
37
src/main/java/com/honey/honey/config/CorsConfig.java
Normal file
37
src/main/java/com/honey/honey/config/CorsConfig.java
Normal file
@@ -0,0 +1,37 @@
|
||||
package com.honey.honey.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class CorsConfig {
|
||||
|
||||
@Value("${FRONTEND_URL:https://example.com}")
|
||||
private String frontendUrl;
|
||||
|
||||
@Bean
|
||||
public WebMvcConfigurer corsConfigurer() {
|
||||
return new WebMvcConfigurer() {
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(
|
||||
frontendUrl,
|
||||
"https://web.telegram.org",
|
||||
"https://webk.telegram.org",
|
||||
"https://t.me",
|
||||
"https://*.t.me"
|
||||
)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
13
src/main/java/com/honey/honey/config/TelegramProperties.java
Normal file
13
src/main/java/com/honey/honey/config/TelegramProperties.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.honey.honey.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "telegram")
|
||||
@Data
|
||||
public class TelegramProperties {
|
||||
private String botToken;
|
||||
}
|
||||
|
||||
20
src/main/java/com/honey/honey/config/WebConfig.java
Normal file
20
src/main/java/com/honey/honey/config/WebConfig.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.honey.honey.config;
|
||||
|
||||
import com.honey.honey.security.AuthInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final AuthInterceptor authInterceptor;
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
19
src/main/java/com/honey/honey/controller/PingController.java
Normal file
19
src/main/java/com/honey/honey/controller/PingController.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
public class PingController {
|
||||
|
||||
@GetMapping("/ping")
|
||||
public Map<String, String> ping() {
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("status", "ok");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
28
src/main/java/com/honey/honey/controller/UserController.java
Normal file
28
src/main/java/com/honey/honey/controller/UserController.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.UserDto;
|
||||
import com.honey.honey.model.User;
|
||||
import com.honey.honey.security.UserContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
@GetMapping("/current")
|
||||
public UserDto getCurrentUser() {
|
||||
User user = UserContext.get();
|
||||
|
||||
return UserDto.builder()
|
||||
.telegram_id(user.getTelegramId())
|
||||
.username(user.getUsername())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
16
src/main/java/com/honey/honey/dto/UserDto.java
Normal file
16
src/main/java/com/honey/honey/dto/UserDto.java
Normal file
@@ -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 UserDto {
|
||||
private Long telegram_id;
|
||||
private String username;
|
||||
}
|
||||
|
||||
14
src/main/java/com/honey/honey/exception/ErrorResponse.java
Normal file
14
src/main/java/com/honey/honey/exception/ErrorResponse.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.honey.honey.exception;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ErrorResponse {
|
||||
private String code;
|
||||
private String message;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.honey.honey.exception;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(UnauthorizedException.class)
|
||||
public ResponseEntity<ErrorResponse> handleUnauthorized(UnauthorizedException ex) {
|
||||
log.warn("Unauthorized: {}", ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(new ErrorResponse("UNAUTHORIZED", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
||||
log.error("Unexpected error", ex);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.honey.honey.exception;
|
||||
|
||||
public class UnauthorizedException extends RuntimeException {
|
||||
public UnauthorizedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.honey.honey.health;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DatabaseHealthIndicator implements HealthIndicator {
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
if (connection.isValid(1)) {
|
||||
return Health.up()
|
||||
.withDetail("database", "MySQL")
|
||||
.withDetail("status", "Connected")
|
||||
.build();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
log.error("Database health check failed", e);
|
||||
return Health.down()
|
||||
.withDetail("database", "MySQL")
|
||||
.withDetail("error", e.getMessage())
|
||||
.build();
|
||||
}
|
||||
return Health.down().withDetail("database", "MySQL").build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.honey.honey.logging;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* Configuration for Grafana integration.
|
||||
* This class prepares the logging infrastructure for Grafana.
|
||||
*
|
||||
* In production (Inferno), logs will be sent to Grafana via:
|
||||
* - Loki (log aggregation)
|
||||
* - Prometheus (metrics)
|
||||
*
|
||||
* For now, this is a placeholder that ensures structured logging
|
||||
* is ready for Grafana integration.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class GrafanaLoggingConfig {
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("📊 Grafana logging configuration initialized");
|
||||
log.info("📊 Logs are structured and ready for Grafana/Loki integration");
|
||||
log.info("📊 Metrics will be available for Prometheus when configured");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log structured data for Grafana.
|
||||
* This method can be used to send custom logs to Grafana/Loki.
|
||||
*
|
||||
* @param level Log level (INFO, WARN, ERROR, etc.)
|
||||
* @param message Log message
|
||||
* @param metadata Additional metadata as key-value pairs
|
||||
*/
|
||||
public static void logToGrafana(String level, String message, java.util.Map<String, Object> metadata) {
|
||||
// For now, just use standard logging
|
||||
// In production, this will send logs to Grafana/Loki
|
||||
switch (level.toUpperCase()) {
|
||||
case "ERROR":
|
||||
log.error("{} | Metadata: {}", message, metadata);
|
||||
break;
|
||||
case "WARN":
|
||||
log.warn("{} | Metadata: {}", message, metadata);
|
||||
break;
|
||||
case "INFO":
|
||||
default:
|
||||
log.info("{} | Metadata: {}", message, metadata);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
src/main/java/com/honey/honey/model/User.java
Normal file
37
src/main/java/com/honey/honey/model/User.java
Normal file
@@ -0,0 +1,37 @@
|
||||
package com.honey.honey.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "telegram_id", unique = true, nullable = false)
|
||||
private Long telegramId;
|
||||
|
||||
@Column(name = "username")
|
||||
private String username;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
src/main/java/com/honey/honey/repository/UserRepository.java
Normal file
15
src/main/java/com/honey/honey/repository/UserRepository.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.honey.honey.repository;
|
||||
|
||||
import com.honey.honey.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
List<User> findAllByTelegramId(Long telegramId);
|
||||
Optional<User> findByTelegramId(Long telegramId);
|
||||
}
|
||||
|
||||
118
src/main/java/com/honey/honey/security/AuthInterceptor.java
Normal file
118
src/main/java/com/honey/honey/security/AuthInterceptor.java
Normal file
@@ -0,0 +1,118 @@
|
||||
package com.honey.honey.security;
|
||||
|
||||
import com.honey.honey.model.User;
|
||||
import com.honey.honey.repository.UserRepository;
|
||||
import com.honey.honey.service.TelegramAuthService;
|
||||
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;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final TelegramAuthService telegramAuthService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
|
||||
|
||||
// Allow CORS preflight (OPTIONS) without auth
|
||||
if ("OPTIONS".equalsIgnoreCase(req.getMethod())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get initData from Authorization header (format: "tma <initData>")
|
||||
String authHeader = req.getHeader("Authorization");
|
||||
String initData = null;
|
||||
|
||||
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 <initData>')");
|
||||
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate Telegram digital signature
|
||||
Map<String, Object> tgUser = telegramAuthService.validateAndParseInitData(initData);
|
||||
|
||||
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);
|
||||
|
||||
// Put user in context
|
||||
UserContext.set(user);
|
||||
log.debug("🔑 Authenticated userId={}, telegramId={}", user.getId(), telegramId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets existing user or creates a new one. Handles race conditions and duplicate users gracefully.
|
||||
*/
|
||||
private synchronized User getOrCreateUser(Long telegramId, String username) {
|
||||
// Try to find existing user(s)
|
||||
List<User> 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;
|
||||
}
|
||||
|
||||
// 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<User> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) {
|
||||
UserContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
21
src/main/java/com/honey/honey/security/UserContext.java
Normal file
21
src/main/java/com/honey/honey/security/UserContext.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.honey.honey.security;
|
||||
|
||||
import com.honey.honey.model.User;
|
||||
|
||||
public class UserContext {
|
||||
|
||||
private static final ThreadLocal<User> current = new ThreadLocal<>();
|
||||
|
||||
public static void set(User user) {
|
||||
current.set(user);
|
||||
}
|
||||
|
||||
public static User get() {
|
||||
return current.get();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
current.remove();
|
||||
}
|
||||
}
|
||||
|
||||
179
src/main/java/com/honey/honey/service/TelegramAuthService.java
Normal file
179
src/main/java/com/honey/honey/service/TelegramAuthService.java
Normal file
@@ -0,0 +1,179 @@
|
||||
package com.honey.honey.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.honey.honey.config.TelegramProperties;
|
||||
import com.honey.honey.exception.UnauthorizedException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TelegramAuthService {
|
||||
|
||||
private static final String HMAC_SHA256_ALGORITHM = "HmacSHA256";
|
||||
private static final String WEB_APP_DATA_CONSTANT = "WebAppData";
|
||||
|
||||
private final TelegramProperties telegramProperties;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Validates and parses Telegram initData string.
|
||||
*/
|
||||
public Map<String, Object> validateAndParseInitData(String initData) {
|
||||
|
||||
if (initData == null || initData.isBlank()) {
|
||||
throw new UnauthorizedException("Telegram initData is missing");
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1. Parse query string into key/value pairs.
|
||||
Map<String, String> parsedData = parseQueryString(initData);
|
||||
|
||||
String receivedHash = parsedData.remove("hash");
|
||||
if (receivedHash == null) {
|
||||
throw new UnauthorizedException("Missing Telegram hash");
|
||||
}
|
||||
|
||||
// Step 2. Build data check string.
|
||||
String dataCheckString = createDataCheckString(parsedData);
|
||||
|
||||
// Step 3. Derive secret key based on Telegram WebApp rules.
|
||||
byte[] secretKey = deriveSecretKey(telegramProperties.getBotToken());
|
||||
|
||||
// Step 4. Calculate our own hash and compare.
|
||||
String calculatedHash = calculateHmacSha256(dataCheckString, secretKey);
|
||||
|
||||
if (!receivedHash.equals(calculatedHash)) {
|
||||
log.warn("Telegram signature mismatch. Expected={}, Received={}", calculatedHash, receivedHash);
|
||||
throw new UnauthorizedException("Invalid Telegram signature");
|
||||
}
|
||||
|
||||
// Step 5. Extract the user JSON from initData.
|
||||
Map<String, String> decoded = decodeQueryParams(initData);
|
||||
String userJson = decoded.get("user");
|
||||
|
||||
if (userJson == null) {
|
||||
throw new UnauthorizedException("initData does not contain 'user' field");
|
||||
}
|
||||
|
||||
// Step 6. Parse JSON into map.
|
||||
return objectMapper.readValue(userJson, Map.class);
|
||||
|
||||
} catch (UnauthorizedException ex) {
|
||||
throw ex;
|
||||
|
||||
} catch (Exception ex) {
|
||||
log.error("Telegram initData validation failed: {}", ex.getMessage(), ex);
|
||||
throw new UnauthorizedException("Invalid Telegram initData");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------
|
||||
// Internal helper methods
|
||||
// -------------------------------------------
|
||||
|
||||
private Map<String, String> parseQueryString(String queryString) {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
|
||||
try {
|
||||
String[] pairs = queryString.split("&");
|
||||
|
||||
for (String pair : pairs) {
|
||||
int idx = pair.indexOf("=");
|
||||
|
||||
if (idx <= 0) continue;
|
||||
|
||||
String key = URLDecoder.decode(pair.substring(0, idx), UTF_8);
|
||||
String value = URLDecoder.decode(pair.substring(idx + 1), UTF_8);
|
||||
|
||||
params.put(key, value);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to parse initData query string: {}", ex.getMessage());
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
private String createDataCheckString(Map<String, String> data) {
|
||||
List<String> sortedKeys = new ArrayList<>(data.keySet());
|
||||
Collections.sort(sortedKeys);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
for (String key : sortedKeys) {
|
||||
if (sb.length() > 0) sb.append("\n");
|
||||
sb.append(key).append("=").append(data.get(key));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private byte[] deriveSecretKey(String botToken) throws NoSuchAlgorithmException, InvalidKeyException {
|
||||
Mac hmacSha256 = Mac.getInstance(HMAC_SHA256_ALGORITHM);
|
||||
|
||||
// Telegram requires using "WebAppData" as the key for deriving secret
|
||||
SecretKeySpec secretKeySpec =
|
||||
new SecretKeySpec(WEB_APP_DATA_CONSTANT.getBytes(StandardCharsets.UTF_8), HMAC_SHA256_ALGORITHM);
|
||||
|
||||
hmacSha256.init(secretKeySpec);
|
||||
|
||||
return hmacSha256.doFinal(botToken.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private String calculateHmacSha256(String data, byte[] key)
|
||||
throws NoSuchAlgorithmException, InvalidKeyException {
|
||||
|
||||
Mac hmacSha256 = Mac.getInstance(HMAC_SHA256_ALGORITHM);
|
||||
SecretKeySpec secretKeySpec = new SecretKeySpec(key, HMAC_SHA256_ALGORITHM);
|
||||
|
||||
hmacSha256.init(secretKeySpec);
|
||||
|
||||
byte[] hashBytes = hmacSha256.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
return bytesToHex(hashBytes);
|
||||
}
|
||||
|
||||
private String bytesToHex(byte[] bytes) {
|
||||
StringBuilder hexString = new StringBuilder(bytes.length * 2);
|
||||
|
||||
for (byte b : bytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
|
||||
if (hex.length() == 1) hexString.append('0');
|
||||
|
||||
hexString.append(hex);
|
||||
}
|
||||
|
||||
return hexString.toString();
|
||||
}
|
||||
|
||||
private Map<String, String> decodeQueryParams(String qs) {
|
||||
Map<String, String> map = new HashMap<>();
|
||||
|
||||
for (String part : qs.split("&")) {
|
||||
int idx = part.indexOf('=');
|
||||
|
||||
if (idx > 0) {
|
||||
String key = URLDecoder.decode(part.substring(0, idx), UTF_8);
|
||||
String val = URLDecoder.decode(part.substring(idx + 1), UTF_8);
|
||||
map.put(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
68
src/main/resources/application.yml
Normal file
68
src/main/resources/application.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: honey-be
|
||||
|
||||
datasource:
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/honey_db}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:root}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:password}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
connection-timeout: 30000
|
||||
initialization-fail-timeout: -1
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: false
|
||||
dialect: org.hibernate.dialect.MySQLDialect
|
||||
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
baseline-on-migrate: true
|
||||
connect-retries: 20
|
||||
connect-retry-interval: 3000
|
||||
validate-on-migrate: false
|
||||
repair: true
|
||||
|
||||
telegram:
|
||||
bot-token: ${TELEGRAM_BOT_TOKEN}
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
org.springframework.boot.context.config: DEBUG
|
||||
org.springframework.core.env: DEBUG
|
||||
com.honey: DEBUG
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
base-path: /actuator
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
probes:
|
||||
enabled: true
|
||||
group:
|
||||
readiness:
|
||||
include: db,ping
|
||||
liveness:
|
||||
include: ping
|
||||
health:
|
||||
db:
|
||||
enabled: true
|
||||
diskspace:
|
||||
enabled: false
|
||||
ping:
|
||||
enabled: true
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Create users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
telegram_id BIGINT NOT NULL UNIQUE,
|
||||
username VARCHAR(255),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_telegram_id (telegram_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
Reference in New Issue
Block a user