- 모바일 약관 탭 UI 개선 - 스크롤 추가 및 스타일 조정
- 필수 입력 경고 메세지 추가 및 인증 미완료 경고 추가
This commit is contained in:
@@ -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.repository.TwoFactorAuthRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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.util.Optional;
|
||||
|
||||
@Component
|
||||
public class AuthNumberStorage {
|
||||
|
||||
private static final String HASH_ALGORITHM = "HmacSHA256";
|
||||
|
||||
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||
|
||||
/**
|
||||
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
|
||||
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
|
||||
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
|
||||
*/
|
||||
@Value("${encryption.key:kjbank_portal_application_1357902}")
|
||||
private String hashPepper;
|
||||
|
||||
@Autowired
|
||||
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
|
||||
this.twoFactorAuthRepository = twoFactorAuthRepository;
|
||||
}
|
||||
|
||||
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);
|
||||
auth.setRecipientKey(lookupKey);
|
||||
twoFactorAuthRepository.save(auth);
|
||||
}
|
||||
|
||||
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
|
||||
return twoFactorAuthRepository.findByRecipient(recipientKey);
|
||||
return twoFactorAuthRepository.findByRecipientKey(hashRecipient(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.concurrent.TimeUnit;
|
||||
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.ServletComponentScan;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
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.web.PageableHandlerMethodArgumentResolver;
|
||||
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.multipart.support.MultipartFilter;
|
||||
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.ViewControllerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
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;
|
||||
|
||||
|
||||
@@ -32,6 +38,16 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
|
||||
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
|
||||
@@ -61,16 +77,45 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
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());
|
||||
registry.addResourceHandler("/images/**").addResourceLocations("/images/", "classpath:/static/images/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/img/**").addResourceLocations("/img/", "classpath:/static/img/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/js/**").addResourceLocations("/js/", "classpath:/static/js/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
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());
|
||||
/**
|
||||
* 정적자원 핸들러 공통 등록.
|
||||
*
|
||||
* <p>콘텐츠 해시 버전닝이 켜져 있으면 {@link VersionResourceResolver} 를 체인 앞단에 추가하여
|
||||
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
||||
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
||||
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
|
||||
*/
|
||||
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("/*");
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thymeleaf {@code @{...}} 링크를 해시 버전 URL 로 치환하기 위한 필터.
|
||||
* {@code @EnableWebMvc} 환경에서는 자동 등록되지 않으므로 명시적으로 빈 등록한다.
|
||||
*/
|
||||
@Bean
|
||||
public ResourceUrlEncodingFilter resourceUrlEncodingFilter() {
|
||||
return new ResourceUrlEncodingFilter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ gateway:
|
||||
|
||||
|
||||
portal:
|
||||
auth-virtual-code: 654321
|
||||
# auth-virtual-code: 654321
|
||||
test-auth-notice-enabled: true
|
||||
dev:
|
||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||
|
||||
@@ -11,13 +11,9 @@ spring:
|
||||
cachecontrol:
|
||||
max-age: 86400
|
||||
public: true
|
||||
# 정적자원 콘텐츠 해시 버전닝: 운영은 항상 ON (파라미터로 비활성화 불가).
|
||||
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
|
||||
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
|
||||
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
|
||||
chain:
|
||||
strategy:
|
||||
content:
|
||||
enabled: true
|
||||
paths: /**
|
||||
jpa:
|
||||
properties:
|
||||
hibernate:
|
||||
|
||||
@@ -33,15 +33,10 @@ spring:
|
||||
max-age: 0
|
||||
must-revalidate: true
|
||||
no-cache: true
|
||||
# 정적자원 콘텐츠 해시 버전닝: /css/main.css -> /css/main-<hash>.css 자동 치환.
|
||||
# 내용이 바뀌면 URL이 바뀌어 강제 새로고침 없이 캐시가 무효화됨.
|
||||
# 비-prod 는 app.resource-versioning.enabled 파라미터로 제어(기본 ON),
|
||||
# false 로 두면 버전닝을 꺼서 sourceMap 디버깅이 가능. (prod 는 application-prod.yml 에서 항상 ON)
|
||||
chain:
|
||||
strategy:
|
||||
content:
|
||||
enabled: ${app.resource-versioning.enabled:true}
|
||||
paths: /**
|
||||
# 정적자원 콘텐츠 해시 버전닝(/css/main.css -> /css/main-<hash>.css)은
|
||||
# @EnableWebMvc 로 WebMvcAutoConfiguration 이 비활성화되어 spring.web.resources.chain
|
||||
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
|
||||
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
|
||||
|
||||
jta:
|
||||
enabled: true
|
||||
@@ -61,7 +56,7 @@ spring:
|
||||
|
||||
# 정적자원 해시 버전닝 토글 (prod 제외 전 프로파일에 적용).
|
||||
# false 로 변경 시 /css/main.css 가 해시 없이 그대로 서빙되어 sourceMap 디버깅 가능.
|
||||
# prod 는 application-prod.yml 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: true
|
||||
@@ -257,7 +252,7 @@ page:
|
||||
path: "#"
|
||||
children:
|
||||
terms:
|
||||
name: "이용약관 및 개인정보처리방침"
|
||||
name: "이용약관"
|
||||
path: "/agreements/terms"
|
||||
about:
|
||||
name: 안내
|
||||
|
||||
@@ -17822,6 +17822,13 @@ body.commission-print-page .btn-primary:hover {
|
||||
@media (max-width: 768px) {
|
||||
.terms-tabs {
|
||||
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 {
|
||||
@@ -17843,8 +17850,10 @@ body.commission-print-page .btn-primary:hover {
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.terms-tabs .tab-link {
|
||||
padding: 16px 16px;
|
||||
font-size: 16px;
|
||||
flex: 1 0 auto;
|
||||
white-space: nowrap;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -86,6 +86,15 @@
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
padding: 0;
|
||||
// 탭이 가로폭을 넘어갈 경우 가로 스크롤 허용
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none; // Firefox
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none; // Chrome/Safari
|
||||
}
|
||||
}
|
||||
|
||||
.tab-link {
|
||||
@@ -107,8 +116,11 @@
|
||||
border-radius: 12px 12px 0 0;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,9 @@
|
||||
|
||||
form.submit();
|
||||
} else {
|
||||
customPopups.showAlert('모든 필수 항목을 입력하고 약관에 동의해주세요.');
|
||||
customPopups.showAlert(!isAuthVerified
|
||||
? '인증번호확인을 완료해주세요.'
|
||||
: '모든 필수 항목을 입력하고 약관에 동의해주세요.');
|
||||
isSubmitting = false;
|
||||
btn_register.disabled = false;
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
|
||||
Reference in New Issue
Block a user