From f32de323bfe0b51273ec6328ae2b7517ecfdb62a Mon Sep 17 00:00:00 2001 From: root Date: Fri, 28 Nov 2025 17:29:37 +0300 Subject: [PATCH] add auth controller --- .idea/uiDesigner.xml | 124 ++++++++++++++++++ .../ru/copperside/config/AuthProperties.java | 62 +++++++++ .../copperside/controller/AuthController.java | 112 ++++++++++++++++ .../exception/GlobalExceptionHandler.java | 97 ++++++++++++++ .../InsufficientPermissionException.java | 10 ++ .../InvalidUserNameOrSignatureException.java | 10 ++ .../exception/dto/ErrorResponseDto.java | 24 ++++ .../ru/copperside/logging/AuthLogger.java | 92 +++++++++++++ .../copperside/model/authinfo/AuthInfo.java | 2 +- .../service/AuthenticationService.java | 15 +++ .../service/AuthenticationServiceImpl.java | 81 ++++++++++++ src/main/resources/application.yaml | 34 +++++ 12 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 .idea/uiDesigner.xml create mode 100644 src/main/java/ru/copperside/config/AuthProperties.java create mode 100644 src/main/java/ru/copperside/controller/AuthController.java create mode 100644 src/main/java/ru/copperside/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/ru/copperside/exception/InsufficientPermissionException.java create mode 100644 src/main/java/ru/copperside/exception/InvalidUserNameOrSignatureException.java create mode 100644 src/main/java/ru/copperside/exception/dto/ErrorResponseDto.java create mode 100644 src/main/java/ru/copperside/logging/AuthLogger.java create mode 100644 src/main/java/ru/copperside/service/AuthenticationService.java create mode 100644 src/main/java/ru/copperside/service/AuthenticationServiceImpl.java diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 0000000..2b63946 --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/ru/copperside/config/AuthProperties.java b/src/main/java/ru/copperside/config/AuthProperties.java new file mode 100644 index 0000000..ae960eb --- /dev/null +++ b/src/main/java/ru/copperside/config/AuthProperties.java @@ -0,0 +1,62 @@ +package ru.copperside.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +@Data +@Component +@ConfigurationProperties(prefix = "auth") +public class AuthProperties { + + private String endpoint = "/auth"; + + private Headers headers = new Headers(); + private Response response = new Response(); + private Logging logging = new Logging(); + private HeaderForwarding headerForwarding = new HeaderForwarding(); + + private Set returnSessionPaths = new HashSet<>(); + + public boolean shouldReturnSessionForPath(String sourcePath) { + return returnSessionPaths.contains(sourcePath); + } + + @Data + public static class Headers { + private String login; + private String signature; + private String originalPath; + } + + @Data + public static class Response { + private String sessionFieldName = "SessionId"; + } + + @Data + public static class Logging { + private boolean logRequestBody = true; + private boolean logResponseBody = true; + private boolean logHeaders = true; + } + + @Data + public static class HeaderForwarding { + private boolean enabled = true; + private Set exclude = new HashSet<>(); + + public boolean isExcluded(String headerName) { + if (headerName == null) return true; + String lower = headerName.toLowerCase(Locale.ROOT); + + return exclude.stream() + .map(s -> s.toLowerCase(Locale.ROOT)) + .anyMatch(lower::equals); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/copperside/controller/AuthController.java b/src/main/java/ru/copperside/controller/AuthController.java new file mode 100644 index 0000000..a8cacdf --- /dev/null +++ b/src/main/java/ru/copperside/controller/AuthController.java @@ -0,0 +1,112 @@ +package ru.copperside.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import ru.copperside.config.AuthProperties; +import ru.copperside.logging.AuthLogger; +import ru.copperside.service.AuthenticationService; + +import java.util.Map; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping +public class AuthController { + + private final AuthenticationService authenticationService; + private final ObjectMapper objectMapper; + private final AuthProperties props; + private final AuthLogger authLogger; + + @PostMapping( + path = "${auth.endpoint}", + consumes = "application/json", + produces = "application/json" + ) + public ResponseEntity authenticate( + @RequestBody(required = false) JsonNode body, + HttpServletRequest request + ) { + // Собираем ВСЕ заголовки + Map headers = authLogger.extractHeaders(request); + + // Пробуем сохранить бизнес-URI (X-Original-Path или что ты настроишь) + String originalPathHeaderName = props.getHeaders().getOriginalPath(); + if (originalPathHeaderName != null) { + String originalPath = headers.get(originalPathHeaderName); + if (originalPath != null && !originalPath.isBlank()) { + request.setAttribute("ORIGINAL_PATH", originalPath); + } + } + + // Логируем запрос (по конфигу) + authLogger.logRequest(request, body, headers); + + // Валидация логина/подписи, прав и логика сессии — ТОЛЬКО в сервисе + authenticationService.authenticate(headers, body, request); + + // Формируем тело ответа: по контракту возвращаем исходный body + JsonNode responseBody = body; + + // При необходимости добавляем SessionId + if (Boolean.TRUE.equals(request.getAttribute("RETURN_SESSION"))) { + String sessionId = (String) request.getAttribute("SESSION_ID"); + responseBody = addSessionToBody(body, sessionId); + } + + // Пробрасываем заголовки назад (за исключением служебных) + HttpHeaders responseHeaders = buildForwardHeaders(headers); + + // Логируем ответ (по конфигу) + authLogger.logResponse(request, responseBody); + + return ResponseEntity.ok() + .headers(responseHeaders) + .body(responseBody); + } + + private HttpHeaders buildForwardHeaders(Map headers) { + HttpHeaders resp = new HttpHeaders(); + var cfg = props.getHeaderForwarding(); + + if (!cfg.isEnabled()) { + return resp; + } + + headers.forEach((name, value) -> { + if (!cfg.isExcluded(name)) { + resp.add(name, value); + } + }); + + return resp; + } + + private JsonNode addSessionToBody(JsonNode body, String sessionId) { + String field = props.getResponse().getSessionFieldName(); + + if (body == null || body.isNull()) { + ObjectNode node = objectMapper.createObjectNode(); + node.put(field, sessionId); + return node; + } + + if (body.isObject()) { + ((ObjectNode) body).put(field, sessionId); + return body; + } + + ObjectNode wrapper = objectMapper.createObjectNode(); + wrapper.set("data", body); + wrapper.put(field, sessionId); + return wrapper; + } +} \ No newline at end of file diff --git a/src/main/java/ru/copperside/exception/GlobalExceptionHandler.java b/src/main/java/ru/copperside/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d470d42 --- /dev/null +++ b/src/main/java/ru/copperside/exception/GlobalExceptionHandler.java @@ -0,0 +1,97 @@ +package ru.copperside.exception; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +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; +import ru.copperside.exception.dto.ErrorResponseDto; +import ru.copperside.logging.AuthLogger; + +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + private static final String EXCEPTION_TYPE = "Error"; + + private final AuthLogger authLogger; + + @ExceptionHandler(InvalidUserNameOrSignatureException.class) + public ResponseEntity handleInvalidUserNameOrSignature( + InvalidUserNameOrSignatureException ex, + HttpServletRequest request + ) { + String sourcePath = resolveSourcePath(request); + String sessionId = resolveSessionId(request); + + ErrorResponseDto body = ErrorResponseDto.builder() + .ExceptionType(EXCEPTION_TYPE) + .Code("InvalidUserNameOrSignature") + .Message("InvalidUserNameOrSignature") + .AdapterName("") + .SourceType("") + .SourcePath(sourcePath) + .SessionId(sessionId.isEmpty() ? null : sessionId) + .TimeStamp(OffsetDateTime.now()) + .Properties(Map.of("Cause", "Данные не соответствуют подписи")) + .build(); + + int status = HttpStatus.UNAUTHORIZED.value(); + + // логируем в структурированный лог + authLogger.logError(request, body, status); + + // при желании оставляем и "человеческий" лог + log.warn("InvalidUserNameOrSignature: {}", ex.getMessage()); + + return ResponseEntity.status(status).body(body); + } + + @ExceptionHandler(InsufficientPermissionException.class) + public ResponseEntity handleInsufficientPermission( + InsufficientPermissionException ex, + HttpServletRequest request + ) { + String sourcePath = resolveSourcePath(request); + String sessionId = resolveSessionId(request); + + ErrorResponseDto body = ErrorResponseDto.builder() + .ExceptionType(EXCEPTION_TYPE) + .Code("InsufficientPermission") + .Message("") + .AdapterName("") + .SourceType("") + .SourcePath(sourcePath) + .SessionId(sessionId.isEmpty() ? "" : sessionId) + .TimeStamp(OffsetDateTime.now()) + .build(); + + int status = HttpStatus.FORBIDDEN.value(); + + authLogger.logError(request, body, status); + log.warn("InsufficientPermission: {}", ex.getMessage()); + + return ResponseEntity.status(status).body(body); + } + + private String resolveSourcePath(HttpServletRequest request) { + Object attr = request.getAttribute("ORIGINAL_PATH"); + if (attr instanceof String s && !s.isBlank()) { + return s; + } + return Optional.ofNullable(request.getRequestURI()).orElse(""); + } + + private String resolveSessionId(HttpServletRequest request) { + return Optional.ofNullable(request.getSession(false)) + .map(s -> s.getId()) + .orElse(""); + } +} \ No newline at end of file diff --git a/src/main/java/ru/copperside/exception/InsufficientPermissionException.java b/src/main/java/ru/copperside/exception/InsufficientPermissionException.java new file mode 100644 index 0000000..2824e5e --- /dev/null +++ b/src/main/java/ru/copperside/exception/InsufficientPermissionException.java @@ -0,0 +1,10 @@ +package ru.copperside.exception; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class InsufficientPermissionException extends RuntimeException { + public InsufficientPermissionException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/ru/copperside/exception/InvalidUserNameOrSignatureException.java b/src/main/java/ru/copperside/exception/InvalidUserNameOrSignatureException.java new file mode 100644 index 0000000..bb6c08c --- /dev/null +++ b/src/main/java/ru/copperside/exception/InvalidUserNameOrSignatureException.java @@ -0,0 +1,10 @@ +package ru.copperside.exception; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class InvalidUserNameOrSignatureException extends RuntimeException { + public InvalidUserNameOrSignatureException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/ru/copperside/exception/dto/ErrorResponseDto.java b/src/main/java/ru/copperside/exception/dto/ErrorResponseDto.java new file mode 100644 index 0000000..48ae0a7 --- /dev/null +++ b/src/main/java/ru/copperside/exception/dto/ErrorResponseDto.java @@ -0,0 +1,24 @@ +package ru.copperside.exception.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.time.OffsetDateTime; +import java.util.Map; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ErrorResponseDto { + + private String ExceptionType; + private String Code; + private String Message; + private String AdapterName; + private String SourceType; + private String SourcePath; + private String SessionId; + private OffsetDateTime TimeStamp; + private Map Properties; +} \ No newline at end of file diff --git a/src/main/java/ru/copperside/logging/AuthLogger.java b/src/main/java/ru/copperside/logging/AuthLogger.java new file mode 100644 index 0000000..3b4c926 --- /dev/null +++ b/src/main/java/ru/copperside/logging/AuthLogger.java @@ -0,0 +1,92 @@ +package ru.copperside.logging; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import ru.copperside.config.AuthProperties; +import ru.copperside.exception.dto.ErrorResponseDto; + +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthLogger { + + private final AuthProperties props; + private final ObjectMapper mapper; + + public void logRequest(HttpServletRequest request, + JsonNode body, + Map headers) { + var cfg = props.getLogging(); + if (!cfg.isLogHeaders() && !cfg.isLogRequestBody()) return; + + try { + Map event = new LinkedHashMap<>(); + event.put("event", "auth_request"); + event.put("method", request.getMethod()); + event.put("path", request.getRequestURI()); + event.put("remoteAddr", request.getRemoteAddr()); + + if (cfg.isLogHeaders()) event.put("headers", headers); + if (cfg.isLogRequestBody()) event.put("body", body); + + log.info(mapper.writeValueAsString(event)); + } catch (Exception e) { + log.warn("Failed to log request", e); + } + } + + public void logResponse(HttpServletRequest request, JsonNode body) { + var cfg = props.getLogging(); + if (!cfg.isLogResponseBody()) return; + + try { + Map event = new LinkedHashMap<>(); + event.put("event", "auth_response"); + event.put("path", request.getRequestURI()); + event.put("status", 200); + event.put("body", body); + + log.info(mapper.writeValueAsString(event)); + } catch (Exception e) { + log.warn("Failed to log response", e); + } + } + + /** + * Логирование ОШИБОЧНОГО ответа. + * Логируем всегда, независимо от флагов (ошибки терять нельзя). + */ + public void logError(HttpServletRequest request, + ErrorResponseDto errorBody, + int status) { + try { + Map event = new LinkedHashMap<>(); + event.put("event", "auth_error"); + event.put("path", request.getRequestURI()); + event.put("status", status); + event.put("error", errorBody); + + log.warn(mapper.writeValueAsString(event)); + } catch (Exception e) { + log.warn("Failed to log error response", e); + } + } + + public Map extractHeaders(HttpServletRequest request) { + Map map = new LinkedHashMap<>(); + Enumeration names = request.getHeaderNames(); + while (names != null && names.hasMoreElements()) { + String n = names.nextElement(); + map.put(n, request.getHeader(n)); + } + return map; + } +} \ No newline at end of file diff --git a/src/main/java/ru/copperside/model/authinfo/AuthInfo.java b/src/main/java/ru/copperside/model/authinfo/AuthInfo.java index a2b56a4..8a813de 100644 --- a/src/main/java/ru/copperside/model/authinfo/AuthInfo.java +++ b/src/main/java/ru/copperside/model/authinfo/AuthInfo.java @@ -13,12 +13,12 @@ import java.util.List; public record AuthInfo( Long authId, String dataId, + Long merchId, Long hierarchyId, String displayName, Boolean isEnabled, Boolean needActivation, List permissions, - SessionSettings sessionSettings, JsonNode sessionData, JsonNode privateData, SecretData secretData diff --git a/src/main/java/ru/copperside/service/AuthenticationService.java b/src/main/java/ru/copperside/service/AuthenticationService.java new file mode 100644 index 0000000..37fb193 --- /dev/null +++ b/src/main/java/ru/copperside/service/AuthenticationService.java @@ -0,0 +1,15 @@ +package ru.copperside.service; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Map; + +public interface AuthenticationService { + + void authenticate( + Map headers, + JsonNode body, + HttpServletRequest request + ); +} \ No newline at end of file diff --git a/src/main/java/ru/copperside/service/AuthenticationServiceImpl.java b/src/main/java/ru/copperside/service/AuthenticationServiceImpl.java new file mode 100644 index 0000000..e124f3f --- /dev/null +++ b/src/main/java/ru/copperside/service/AuthenticationServiceImpl.java @@ -0,0 +1,81 @@ +package ru.copperside.service; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import ru.copperside.service.AuthenticationService; +import ru.copperside.config.AuthProperties; +import ru.copperside.exception.InsufficientPermissionException; +import ru.copperside.exception.InvalidUserNameOrSignatureException; + +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthenticationServiceImpl implements AuthenticationService { + + private final AuthProperties props; + + @Override + public void authenticate(Map headers, + JsonNode body, + HttpServletRequest request) { + + // 1. Достаём логин и подпись из заголовков + String loginHeaderName = props.getHeaders().getLogin(); + String signatureHeaderName = props.getHeaders().getSignature(); + + String login = headers.get(loginHeaderName); + String signature = headers.get(signatureHeaderName); + + if (isBlank(login) || isBlank(signature)) { + // Отсутствие нужных заголовков — это та же ошибка InvalidUserNameOrSignature + throw new InvalidUserNameOrSignatureException("Missing login or signature header"); + } + + // 2. Проверка подписи (заглушка; здесь можешь использовать login/signature/body/headers) + boolean signatureOk = true; // TODO: реальная проверка HMAC + if (!signatureOk) { + throw new InvalidUserNameOrSignatureException("Invalid signature"); + } + + // 3. Проверка прав (заглушка; тут уже знаешь authId/permissions и т.д.) + boolean hasPerm = true; // TODO: реальная проверка прав + if (!hasPerm) { + throw new InsufficientPermissionException("Insufficient permissions"); + } + + // 4. Работа с SessionId при необходимости + String sourcePath = resolveSourcePath(request); + + if (props.shouldReturnSessionForPath(sourcePath)) { + HttpSession session = request.getSession(true); + request.setAttribute("RETURN_SESSION", Boolean.TRUE); + request.setAttribute("SESSION_ID", session.getId()); + } + } + + private String resolveSourcePath(HttpServletRequest req) { + Object attr = req.getAttribute("ORIGINAL_PATH"); + if (attr instanceof String s && !s.isBlank()) { + return s; + } + + String headerName = props.getHeaders().getOriginalPath(); + String header = headerName != null ? req.getHeader(headerName) : null; + if (header != null && !header.isBlank()) { + return header; + } + + return Optional.ofNullable(req.getRequestURI()).orElse(""); + } + + private boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index c4595cb..03ca778 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -7,3 +7,37 @@ spring: username: AUTH password: .,z;pws-2shds driver-class-name: oracle.jdbc.OracleDriver + +auth: + endpoint: /auth + + headers: + login: TCB-Header-Login + signature: TCB-Header-Sign + original-path: X-Original-Path + + response: + session-field-name: SessionId + + return-session-paths: + - /api/v1/order/state + + logging: + log-request-body: true + log-response-body: true + log-headers: true + + header-forwarding: + enabled: true + # Заголовки, которые НЕ нужно возвращать (регистр игнорируем) + exclude: + - content-length + - host + - connection + - transfer-encoding + - keep-alive + - proxy-authenticate + - proxy-authorization + - te + - trailer + - upgrade \ No newline at end of file