- 모바일 약관 탭 UI 개선 - 스크롤 추가 및 스타일 조정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- 필수 입력 경고 메세지 추가 및 인증 미완료 경고 추가
This commit is contained in:
Rinjae
2026-06-24 15:32:18 +09:00
parent d7abe2c2e5
commit e242d3ed06
9 changed files with 146 additions and 37 deletions
@@ -3,31 +3,72 @@ package com.eactive.apim.portal.apps.auth.service;
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth; import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository; import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Optional; import java.util.Optional;
@Component @Component
public class AuthNumberStorage { public class AuthNumberStorage {
private static final String HASH_ALGORITHM = "HmacSHA256";
private final TwoFactorAuthRepository twoFactorAuthRepository; private final TwoFactorAuthRepository twoFactorAuthRepository;
/**
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
*/
@Value("${encryption.key:kjbank_portal_application_1357902}")
private String hashPepper;
@Autowired @Autowired
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) { public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
this.twoFactorAuthRepository = twoFactorAuthRepository; this.twoFactorAuthRepository = twoFactorAuthRepository;
} }
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) { public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
twoFactorAuthRepository.deleteAllByRecipient(recipientKey); String lookupKey = hashRecipient(recipientKey);
twoFactorAuthRepository.deleteAllByRecipientKey(lookupKey);
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt); TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
auth.setRecipientKey(lookupKey);
twoFactorAuthRepository.save(auth); twoFactorAuthRepository.save(auth);
} }
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) { public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
return twoFactorAuthRepository.findByRecipient(recipientKey); return twoFactorAuthRepository.findByRecipientKey(hashRecipient(recipientKey));
} }
public void deleteAuthNumber(String recipientKey) { public void deleteAuthNumber(String recipientKey) {
twoFactorAuthRepository.deleteById(recipientKey); twoFactorAuthRepository.deleteAllByRecipientKey(hashRecipient(recipientKey));
}
/**
* recipient 평문을 결정적 해시(HMAC-SHA256, 소문자 hex)로 변환한다.
* 동일 입력 → 동일 키이므로 저장/조회/정리에서 동등 매칭이 가능하다.
*/
private String hashRecipient(String recipient) {
if (recipient == null) {
return null;
}
try {
Mac mac = Mac.getInstance(HASH_ALGORITHM);
mac.init(new SecretKeySpec(hashPepper.getBytes(StandardCharsets.UTF_8), HASH_ALGORITHM));
byte[] digest = mac.doFinal(recipient.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
sb.append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("2FA recipient 해시 생성 실패", e);
}
} }
} }
@@ -5,11 +5,14 @@ import com.eactive.apim.portal.common.converter.EnabledStatusConverter;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect; import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletComponentScan; import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
import org.springframework.format.FormatterRegistry; import org.springframework.format.FormatterRegistry;
@@ -18,10 +21,13 @@ import org.springframework.web.filter.CharacterEncodingFilter;
import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.multipart.support.MultipartFilter; import org.springframework.web.multipart.support.MultipartFilter;
import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceChainRegistration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver; import org.springframework.web.servlet.resource.PathResourceResolver;
import org.springframework.web.servlet.resource.ResourceUrlEncodingFilter;
import org.springframework.web.servlet.resource.VersionResourceResolver;
import org.springframework.web.servlet.view.json.MappingJackson2JsonView; import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
@@ -32,6 +38,16 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
public static final String ERROR = "error"; public static final String ERROR = "error";
private final Environment environment;
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
@Value("${app.resource-versioning.enabled:true}")
private boolean resourceVersioningEnabled;
public PortalConfigWebDispatcherServlet(Environment environment) {
this.environment = environment;
}
@Bean @Bean
@@ -61,16 +77,45 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Override @Override
public void addResourceHandlers(ResourceHandlerRegistry registry) { public void addResourceHandlers(ResourceHandlerRegistry registry) {
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
}
registry.addResourceHandler("/css/**").addResourceLocations("/css/", "classpath:/static/css/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); /**
registry.addResourceHandler("/webfonts/**").addResourceLocations("/webfonts/", "classpath:/static/webfonts/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); * 정적자원 핸들러 공통 등록.
registry.addResourceHandler("/font/**").addResourceLocations("/font/", "classpath:/static/font/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); *
registry.addResourceHandler("/html/**").addResourceLocations("/html/", "classpath:/static/html/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); * <p>콘텐츠 해시 버전닝이 켜져 있으면 {@link VersionResourceResolver} 를 체인 앞단에 추가하여
registry.addResourceHandler("/images/**").addResourceLocations("/images/", "classpath:/static/images/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); * {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
registry.addResourceHandler("/img/**").addResourceLocations("/img/", "classpath:/static/img/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); * 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
registry.addResourceHandler("/js/**").addResourceLocations("/js/", "classpath:/static/js/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); * {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
registry.addResourceHandler("/plugins/**").addResourceLocations("/plugins/", "classpath:/static/plugins/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); */
registry.addResourceHandler("/favicon.ico").addResourceLocations("/favicon.ico", "classpath:/static/favicon.ico").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
.addResourceLocations(locations)
.setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS))
.resourceChain(true);
if (isResourceVersioningEnabled()) {
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
chain.addResolver(new PathResourceResolver());
}
/**
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
*/
private boolean isResourceVersioningEnabled() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
return resourceVersioningEnabled;
} }
@@ -123,4 +168,13 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
registrationBean.addUrlPatterns("/*"); registrationBean.addUrlPatterns("/*");
return registrationBean; return registrationBean;
} }
/**
* Thymeleaf {@code @{...}} 링크를 해시 버전 URL 로 치환하기 위한 필터.
* {@code @EnableWebMvc} 환경에서는 자동 등록되지 않으므로 명시적으로 빈 등록한다.
*/
@Bean
public ResourceUrlEncodingFilter resourceUrlEncodingFilter() {
return new ResourceUrlEncodingFilter();
}
} }
+1 -1
View File
@@ -39,7 +39,7 @@ gateway:
portal: portal:
auth-virtual-code: 654321 # auth-virtual-code: 654321
test-auth-notice-enabled: true test-auth-notice-enabled: true
dev: dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영 # application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
+2 -6
View File
@@ -11,13 +11,9 @@ spring:
cachecontrol: cachecontrol:
max-age: 86400 max-age: 86400
public: true public: true
# 정적자원 콘텐츠 해시 버전닝: 운영은 항상 ON (파라미터로 비활성화 불가). # 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화. # 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
chain:
strategy:
content:
enabled: true
paths: /**
jpa: jpa:
properties: properties:
hibernate: hibernate:
+6 -11
View File
@@ -33,15 +33,10 @@ spring:
max-age: 0 max-age: 0
must-revalidate: true must-revalidate: true
no-cache: true no-cache: true
# 정적자원 콘텐츠 해시 버전닝: /css/main.css -> /css/main-<hash>.css 자동 치환. # 정적자원 콘텐츠 해시 버전닝(/css/main.css -> /css/main-<hash>.css)은
# 내용이 바뀌면 URL이 바뀌어 강제 새로고침 없이 캐시가 무효화됨. # @EnableWebMvc 로 WebMvcAutoConfiguration 이 비활성화되어 spring.web.resources.chain
# 비-prod 는 app.resource-versioning.enabled 파라미터로 제어(기본 ON), # 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
# false 로 두면 버전닝을 꺼서 sourceMap 디버깅이 가능. (prod 는 application-prod.yml 에서 항상 ON) # 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
chain:
strategy:
content:
enabled: ${app.resource-versioning.enabled:true}
paths: /**
jta: jta:
enabled: true enabled: true
@@ -61,7 +56,7 @@ spring:
# 정적자원 해시 버전닝 토글 (prod 제외 전 프로파일에 적용). # 정적자원 해시 버전닝 토글 (prod 제외 전 프로파일에 적용).
# false 로 변경 시 /css/main.css 가 해시 없이 그대로 서빙되어 sourceMap 디버깅 가능. # false 로 변경 시 /css/main.css 가 해시 없이 그대로 서빙되어 sourceMap 디버깅 가능.
# prod 는 application-prod.yml 에서 항상 ON 으로 고정되어 이 값을 무시함. # prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
app: app:
resource-versioning: resource-versioning:
enabled: true enabled: true
@@ -257,7 +252,7 @@ page:
path: "#" path: "#"
children: children:
terms: terms:
name: "이용약관 및 개인정보처리방침" name: "이용약관"
path: "/agreements/terms" path: "/agreements/terms"
about: about:
name: 안내 name: 안내
+11 -2
View File
@@ -17822,6 +17822,13 @@ body.commission-print-page .btn-primary:hover {
@media (max-width: 768px) { @media (max-width: 768px) {
.terms-tabs { .terms-tabs {
padding: 0; padding: 0;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.terms-tabs::-webkit-scrollbar {
display: none;
} }
} }
.terms-tabs .tab-link { .terms-tabs .tab-link {
@@ -17843,8 +17850,10 @@ body.commission-print-page .btn-primary:hover {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.terms-tabs .tab-link { .terms-tabs .tab-link {
padding: 16px 16px; flex: 1 0 auto;
font-size: 16px; white-space: nowrap;
padding: 8px 16px;
font-size: 14px;
gap: 4px; gap: 4px;
} }
} }
File diff suppressed because one or more lines are too long
@@ -86,6 +86,15 @@
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
padding: 0; padding: 0;
// 탭이 가로폭을 넘어갈 경우 가로 스크롤 허용
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; // Firefox
&::-webkit-scrollbar {
display: none; // Chrome/Safari
}
} }
.tab-link { .tab-link {
@@ -107,8 +116,11 @@
border-radius: 12px 12px 0 0; border-radius: 12px 12px 0 0;
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
padding: $spacing-md $spacing-md; // 글자 한 줄 유지 + 글자 크기/높이 축소
font-size: $font-size-base; flex: 1 0 auto;
white-space: nowrap;
padding: $spacing-sm $spacing-md;
font-size: $font-size-sm;
gap: $spacing-xs; gap: $spacing-xs;
} }
@@ -179,7 +179,9 @@
form.submit(); form.submit();
} else { } else {
customPopups.showAlert('모든 필수 항목을 입력하고 약관에 동의해주세요.'); customPopups.showAlert(!isAuthVerified
? '인증번호확인을 완료해주세요.'
: '모든 필수 항목을 입력하고 약관에 동의해주세요.');
isSubmitting = false; isSubmitting = false;
btn_register.disabled = false; btn_register.disabled = false;
document.getElementById('loadingOverlay').classList.remove('active'); document.getElementById('loadingOverlay').classList.remove('active');