@@ -1,6 +1,6 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.domain.token;
|
package com.cloudhandson.vpdbackoffice.domain.token;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
public record BearerTokenRecord(
|
public record BearerTokenRecord(
|
||||||
long keyId,
|
long keyId,
|
||||||
@@ -8,12 +8,12 @@ public record BearerTokenRecord(
|
|||||||
String username,
|
String username,
|
||||||
String keyPrefix,
|
String keyPrefix,
|
||||||
String keyHash,
|
String keyHash,
|
||||||
OffsetDateTime expiresAt,
|
LocalDateTime expiresAt,
|
||||||
OffsetDateTime revokedAt,
|
LocalDateTime revokedAt,
|
||||||
String description
|
String description
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public boolean active(OffsetDateTime now) {
|
public boolean active(LocalDateTime now) {
|
||||||
return revokedAt == null && expiresAt != null && expiresAt.isAfter(now);
|
return revokedAt == null && expiresAt != null && expiresAt.isAfter(now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.mapper;
|
package com.cloudhandson.vpdbackoffice.mapper;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
@@ -19,7 +19,7 @@ public interface BearerTokenMapper {
|
|||||||
|
|
||||||
int revokeToken(
|
int revokeToken(
|
||||||
@Param("keyId") long keyId,
|
@Param("keyId") long keyId,
|
||||||
@Param("revokedAt") OffsetDateTime revokedAt,
|
@Param("revokedAt") LocalDateTime revokedAt,
|
||||||
@Param("reason") String reason
|
@Param("reason") String reason
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
|
|||||||
import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper;
|
import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper;
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -81,6 +83,9 @@ public class BearerTokenService {
|
|||||||
String prefix = tokenGenerator.prefix(plainToken);
|
String prefix = tokenGenerator.prefix(plainToken);
|
||||||
String hash = tokenHasher.sha256(plainToken);
|
String hash = tokenHasher.sha256(plainToken);
|
||||||
long keyId = tokenMapper.nextKeyId();
|
long keyId = tokenMapper.nextKeyId();
|
||||||
|
LocalDateTime expiresAt = command.expiresAt()
|
||||||
|
.atZoneSameInstant(ZoneId.systemDefault())
|
||||||
|
.toLocalDateTime();
|
||||||
|
|
||||||
tokenMapper.insertToken(new BearerTokenRecord(
|
tokenMapper.insertToken(new BearerTokenRecord(
|
||||||
keyId,
|
keyId,
|
||||||
@@ -88,7 +93,7 @@ public class BearerTokenService {
|
|||||||
user.username(),
|
user.username(),
|
||||||
prefix,
|
prefix,
|
||||||
hash,
|
hash,
|
||||||
command.expiresAt(),
|
expiresAt,
|
||||||
null,
|
null,
|
||||||
command.description()
|
command.description()
|
||||||
));
|
));
|
||||||
@@ -98,7 +103,11 @@ public class BearerTokenService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void revokeToken(long keyId, String reason) {
|
public void revokeToken(long keyId, String reason) {
|
||||||
int updated = tokenMapper.revokeToken(keyId, OffsetDateTime.now(clock), reason);
|
int updated = tokenMapper.revokeToken(
|
||||||
|
keyId,
|
||||||
|
LocalDateTime.now(clock.withZone(ZoneId.systemDefault())),
|
||||||
|
reason
|
||||||
|
);
|
||||||
if (updated == 0) {
|
if (updated == 0) {
|
||||||
throw new AppException("회수할 활성 토큰을 찾을 수 없습니다.");
|
throw new AppException("회수할 활성 토큰을 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -71,7 +72,7 @@ public class OrdsProbeService {
|
|||||||
return auditAndReturn(command, ProbeResult.blocked(
|
return auditAndReturn(command, ProbeResult.blocked(
|
||||||
ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다."));
|
ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다."));
|
||||||
}
|
}
|
||||||
if (!token.active(OffsetDateTime.now(clock))) {
|
if (!token.active(LocalDateTime.now(clock.withZone(ZoneId.systemDefault())))) {
|
||||||
return auditAndReturn(command, ProbeResult.blocked(
|
return auditAndReturn(command, ProbeResult.blocked(
|
||||||
ProbeStatus.TOKEN_INACTIVE, "TOKEN_INACTIVE", "만료되었거나 회수된 토큰입니다."));
|
ProbeStatus.TOKEN_INACTIVE, "TOKEN_INACTIVE", "만료되었거나 회수된 토큰입니다."));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,17 @@ public class AppExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(DataAccessException.class)
|
@ExceptionHandler(DataAccessException.class)
|
||||||
public String handleDataAccessException(DataAccessException exception, Model model) {
|
public String handleDataAccessException(DataAccessException exception, Model model) {
|
||||||
String detail = exception.getMostSpecificCause() == null
|
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
|
||||||
? exception.getMessage()
|
model.addAttribute("errorTitle", error.title());
|
||||||
: exception.getMostSpecificCause().getMessage();
|
model.addAttribute("errorMessage", error.message());
|
||||||
model.addAttribute("errorTitle", "ADB 연결 설정이 필요합니다.");
|
return "error";
|
||||||
model.addAttribute("errorMessage",
|
}
|
||||||
"BACKOFFICE_DB_URL, BACKOFFICE_DB_USERNAME, BACKOFFICE_DB_PASSWORD를 실제 ADB 값으로 설정하고 "
|
|
||||||
+ "./run.sh backoffice-support를 실행한 뒤 다시 시도하세요. 상세: " + detail);
|
@ExceptionHandler(Exception.class)
|
||||||
|
public String handleUnexpectedException(Exception exception, Model model) {
|
||||||
|
RuntimeErrorMessage error = RuntimeErrorMessages.unexpected(exception);
|
||||||
|
model.addAttribute("errorTitle", error.title());
|
||||||
|
model.addAttribute("errorMessage", error.message());
|
||||||
return "error";
|
return "error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,18 +33,14 @@ public class DashboardController {
|
|||||||
model.addAttribute("roles", permissionService.findRoles());
|
model.addAttribute("roles", permissionService.findRoles());
|
||||||
model.addAttribute("tokens", bearerTokenService.findAll());
|
model.addAttribute("tokens", bearerTokenService.findAll());
|
||||||
} catch (DataAccessException e) {
|
} catch (DataAccessException e) {
|
||||||
|
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(e);
|
||||||
model.addAttribute("objects", List.of());
|
model.addAttribute("objects", List.of());
|
||||||
model.addAttribute("roles", List.of());
|
model.addAttribute("roles", List.of());
|
||||||
model.addAttribute("tokens", List.of());
|
model.addAttribute("tokens", List.of());
|
||||||
model.addAttribute("setupError", dbSetupMessage(e));
|
model.addAttribute("runtimeErrorTitle", error.title());
|
||||||
|
model.addAttribute("runtimeErrorMessage", error.message());
|
||||||
|
model.addAttribute("showSupportCommand", error.showSupportCommand());
|
||||||
}
|
}
|
||||||
return "dashboard";
|
return "dashboard";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String dbSetupMessage(DataAccessException e) {
|
|
||||||
String detail = e.getMostSpecificCause() == null ? e.getMessage() : e.getMostSpecificCause().getMessage();
|
|
||||||
return "ADB 연결을 확인할 수 없습니다. BACKOFFICE_DB_URL, BACKOFFICE_DB_USERNAME, "
|
|
||||||
+ "BACKOFFICE_DB_PASSWORD를 설정하고 ./run.sh backoffice-support를 먼저 실행하세요. 상세: "
|
|
||||||
+ detail;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.web;
|
||||||
|
|
||||||
|
public record RuntimeErrorMessage(
|
||||||
|
String title,
|
||||||
|
String message,
|
||||||
|
boolean showSupportCommand
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.web;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
|
||||||
|
final class RuntimeErrorMessages {
|
||||||
|
|
||||||
|
private RuntimeErrorMessages() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static RuntimeErrorMessage dataAccess(DataAccessException exception) {
|
||||||
|
String detail = detail(exception);
|
||||||
|
if (looksLikeConnectionProblem(exception, detail)) {
|
||||||
|
return new RuntimeErrorMessage(
|
||||||
|
"DB 연결 설정이 필요합니다.",
|
||||||
|
"ADB에 연결할 수 없습니다. BACKOFFICE_DB_URL, BACKOFFICE_DB_USERNAME, "
|
||||||
|
+ "BACKOFFICE_DB_PASSWORD를 확인하고 ./run.sh backoffice-support를 다시 실행하세요. 상세: "
|
||||||
|
+ detail,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new RuntimeErrorMessage(
|
||||||
|
"데이터 처리 오류가 발생했습니다.",
|
||||||
|
"요청을 처리하는 중 DB 데이터 타입, SQL, 또는 매핑 오류가 발생했습니다. "
|
||||||
|
+ "입력값과 백오피스 테이블 스키마를 확인하세요. 상세: " + detail,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static RuntimeErrorMessage unexpected(Exception exception) {
|
||||||
|
return new RuntimeErrorMessage(
|
||||||
|
"요청 처리 중 오류가 발생했습니다.",
|
||||||
|
"예상하지 못한 오류가 발생했습니다. 입력값을 확인한 뒤 다시 시도하세요. 상세: "
|
||||||
|
+ safeMessage(exception),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean looksLikeConnectionProblem(DataAccessException exception, String detail) {
|
||||||
|
String text = (exception.getMessage() + " " + detail).toLowerCase(Locale.ROOT);
|
||||||
|
return text.contains("connection refused")
|
||||||
|
|| text.contains("the network adapter could not establish the connection")
|
||||||
|
|| text.contains("io error")
|
||||||
|
|| text.contains("ora-01017")
|
||||||
|
|| text.contains("ora-12154")
|
||||||
|
|| text.contains("ora-12514")
|
||||||
|
|| text.contains("ora-12541");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String detail(DataAccessException exception) {
|
||||||
|
Throwable cause = exception.getMostSpecificCause();
|
||||||
|
if (cause instanceof SQLException sqlException) {
|
||||||
|
return trim(sqlException.getMessage());
|
||||||
|
}
|
||||||
|
return trim(cause == null ? exception.getMessage() : safeMessage(cause));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeMessage(Throwable throwable) {
|
||||||
|
String message = throwable.getMessage();
|
||||||
|
return trim(message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return "상세 메시지가 없습니다.";
|
||||||
|
}
|
||||||
|
String normalized = value.replaceAll("\\s+", " ").trim();
|
||||||
|
return normalized.length() <= 300 ? normalized : normalized.substring(0, 300) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
<p>보호 객체, Bearer Token, ORDS 검증 상태를 확인합니다.</p>
|
<p>보호 객체, Bearer Token, ORDS 검증 상태를 확인합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning" th:if="${setupError}">
|
<div class="alert alert-warning" th:if="${runtimeErrorMessage}">
|
||||||
<div class="fw-semibold">DB 연결 설정이 필요합니다.</div>
|
<div class="fw-semibold" th:text="${runtimeErrorTitle}">데이터 처리 오류가 발생했습니다.</div>
|
||||||
<div th:text="${setupError}"></div>
|
<div th:text="${runtimeErrorMessage}"></div>
|
||||||
<div class="mt-2">
|
<div class="mt-2" th:if="${showSupportCommand}">
|
||||||
<code>./run.sh backoffice-support</code>
|
<code>./run.sh backoffice-support</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user