This commit is contained in:
mindol1004
2024-10-04 18:12:59 +09:00
parent f626065b47
commit 630d615c8c
35 changed files with 421 additions and 256 deletions

View File

@@ -18,10 +18,10 @@ import com.spring.domain.post.repository.PostRepository;
@Slf4j
@Component
@BatchJobInfo(
group = "EMAIL",
jobName = "emailSendJob",
cronExpression = "*/10 * * * * ?",
description = "이메일배치작업"
group = "${batch-info.email-send-batch.group}",
jobName = "${batch-info.email-send-batch.job-name}",
cronExpression = "${batch-info.email-send-batch.cron-expression}",
description = "${batch-info.email-send-batch.description}"
)
@RequiredArgsConstructor
public class EmailSendBatch extends AbstractBatchTask {

View File

@@ -19,9 +19,10 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
@BatchJobInfo(
group = "POST",
jobName = "postCreateJob",
cronExpression = "0/20 * * * * ?"
group = "${batch-info.post-batch.group}",
jobName = "${batch-info.post-batch.job-name}",
cronExpression = "${batch-info.post-batch.cron-expression}",
description = "${batch-info.post-batch.description}"
)
@RequiredArgsConstructor
public class PostCreateBatch extends AbstractBatchTask {

View File

@@ -30,8 +30,8 @@ import com.spring.domain.post.entity.Post;
import com.spring.domain.post.entity.PostBackUp;
import com.spring.domain.post.repository.PostBackUpRepository;
import com.spring.domain.post.repository.PostRepository;
import com.spring.infra.batch.BatchJobInfo;
import com.spring.infra.db.orm.jpa.SecondaryJpaConfig;
import com.spring.infra.quartz.QuartzJob;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -55,7 +55,12 @@ public class PostCreateBatchChunk {
private List<Post> list = new ArrayList<>();
@Bean
@QuartzJob(group = "POST", jobName = "testPostJob", cronExpression = "0/50 * * * * ?", description = "테스트배치작업")
@BatchJobInfo(
group = "${batch-info.post-create-batch.group}",
jobName = "${batch-info.post-create-batch.job-name}",
cronExpression = "${batch-info.post-create-batch.cron-expression}",
description = "${batch-info.post-create-batch.description}"
)
Job testPostJob() {
return new JobBuilder("testPostJob")
.repository(jobRepository)

View File

@@ -16,7 +16,8 @@ import javax.persistence.Table;
import org.hibernate.annotations.GenericGenerator;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.spring.infra.security.domain.UserPrincipal;
import lombok.AccessLevel;
import lombok.Builder;
@@ -26,8 +27,8 @@ import lombok.NoArgsConstructor;
@Entity
@Table(name = "AGENT_USER")
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class AgentUser implements UserDetails {
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AgentUser implements UserPrincipal {
@Id
@GeneratedValue(generator = "uuid2")
@@ -95,4 +96,14 @@ public class AgentUser implements UserDetails {
return this.userId;
}
@Override
public String getKey() {
return this.id.toString();
}
@Override
public String getMemberName() {
return this.userName;
}
}

View File

@@ -20,7 +20,7 @@ import lombok.NoArgsConstructor;
@Entity
@Table(name = "AGENT_USER_TOKEN")
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AgentUserToken {
@Id
@@ -35,14 +35,10 @@ public class AgentUserToken {
@Column(name = "REFRESH_TOKEN", nullable = false)
private String refreshToken;
@Column(name = "REISSUE_COUNT", nullable = false)
private int reissueCount;
@Builder
public AgentUserToken(AgentUser agentUser, String refreshToken, int reissueCount) {
public AgentUserToken(AgentUser agentUser, String refreshToken) {
this.agentUser = agentUser;
this.refreshToken = refreshToken;
this.reissueCount = reissueCount;
}
public void updateRefreshToken(String refreshToken) {
@@ -53,8 +49,4 @@ public class AgentUserToken {
return this.refreshToken.equals(refreshToken);
}
public void increaseReissueCount() {
reissueCount++;
}
}

View File

@@ -20,7 +20,7 @@ import lombok.NoArgsConstructor;
@Entity
@Table(name = "APP_USER")
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AppUser {
@Id

View File

@@ -19,7 +19,7 @@ import lombok.NoArgsConstructor;
@Entity
@Table(name = "APP_USER_ROLE")
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AppUserRole {
@Id

View File

@@ -19,7 +19,7 @@ import lombok.NoArgsConstructor;
@Entity
@Table(name = "APP_USER_ROLE_MAP")
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AppUserRoleMap {
@EmbeddedId

View File

@@ -0,0 +1,11 @@
package com.spring.domain.user.error;
import com.spring.common.error.BizBaseException;
public class UserUnAuthorized extends BizBaseException {
public UserUnAuthorized() {
super(UserRule.USER_UNAUTHORIZED);
}
}

View File

@@ -1,6 +1,5 @@
package com.spring.domain.user.repository;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -10,8 +9,4 @@ import com.spring.domain.user.entity.AgentUserToken;
public interface AgentUserTokenRepository extends JpaRepository<AgentUserToken, UUID> {
Optional<AgentUserToken> findByIdAndReissueCountLessThan(UUID id, long count);
Optional<AgentUserToken> findByRefreshToken(String refreshToken);
}

View File

@@ -1,13 +1,16 @@
package com.spring.domain.user.service;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.UUID;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.spring.domain.user.error.UserNotFoundException;
import com.spring.domain.user.error.UserRule;
import com.spring.domain.user.repository.AgentUserRepository;
import com.spring.infra.security.domain.UserPrincipal;
import lombok.RequiredArgsConstructor;
@@ -19,9 +22,15 @@ public class UserPrincipalService implements UserDetailsService {
@Transactional(readOnly = true)
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
public UserPrincipal loadUserByUsername(String username) throws UsernameNotFoundException {
return agentUserRepository.findByUserId(username)
.orElseThrow(() -> new UsernameNotFoundException(UserRule.USER_UNAUTHORIZED.getMessage()));
}
@Transactional(readOnly = true)
public UserPrincipal getUser(String key) {
return agentUserRepository.findById(UUID.fromString(key))
.orElseThrow(UserNotFoundException::new);
}
}

View File

@@ -1,12 +1,14 @@
package com.spring.domain.user.service;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.spring.domain.user.entity.AgentUser;
import com.spring.domain.user.entity.AgentUserToken;
import com.spring.domain.user.error.UserNotFoundException;
import com.spring.domain.user.error.UserUnAuthorized;
import com.spring.domain.user.repository.AgentUserRepository;
import com.spring.domain.user.repository.AgentUserTokenRepository;
import com.spring.infra.security.service.RefreshTokenService;
@@ -22,8 +24,8 @@ public class UserRefreshTokenService implements RefreshTokenService {
@Transactional
@Override
public void saveRefreshToken(UserDetails user, String refreshToken) {
AgentUser agentUser = agentUserRepository.findByUserId(user.getUsername()).orElseThrow(UserNotFoundException::new);
public void saveRefreshToken(String userId, String refreshToken) {
AgentUser agentUser = agentUserRepository.findByUserId(userId).orElseThrow(UserNotFoundException::new);
agentUserTokenRepository.findById(agentUser.getId())
.ifPresentOrElse(
it -> it.updateRefreshToken(refreshToken),
@@ -36,9 +38,19 @@ public class UserRefreshTokenService implements RefreshTokenService {
);
}
@Transactional(readOnly = true)
@Override
public String findByRefreshToken(String refreshToken) {
throw new UnsupportedOperationException("Unimplemented method 'findByRefreshToken'");
public String getRefreshToken(String key) {
return agentUserTokenRepository.findById(UUID.fromString(key)).stream()
.map(AgentUserToken::getRefreshToken)
.findFirst()
.orElseThrow(UserUnAuthorized::new);
}
@Transactional
@Override
public void deleteRefreshToken(String key) {
agentUserTokenRepository.deleteById(UUID.fromString(key));
}
}

View File

@@ -11,6 +11,7 @@ import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
@@ -22,6 +23,8 @@ import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.lang.NonNull;
import org.springframework.transaction.PlatformTransactionManager;
import com.spring.infra.batch.BatchJobInfoBeanPostProcessor.BatchJobInfoData;
/**
* 배치 작업을 정의하는 추상 클래스입니다.
* <p>
@@ -32,9 +35,11 @@ import org.springframework.transaction.PlatformTransactionManager;
* @version 1.0
*/
@Configuration
public abstract class AbstractBatchTask implements ApplicationContextAware {
public abstract class AbstractBatchTask implements ApplicationContextAware, InitializingBean {
private final BatchJobInfo batchJobInfo;
private BatchJobInfoBeanPostProcessor batchJobInfoProcessor;
private BatchJobInfoData batchJobInfoData;
private ApplicationContext applicationContext;
private JobRepository jobRepository;
private PlatformTransactionManager transactionManager;
@@ -52,18 +57,23 @@ public abstract class AbstractBatchTask implements ApplicationContextAware {
}
}
/**
* ApplicationContext를 설정합니다.
*
* @param applicationContext 설정할 ApplicationContext
* @throws BeansException Beans 예외
*/
@Override
public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public void afterPropertiesSet() throws Exception {
this.batchJobInfoProcessor = applicationContext.getBean(BatchJobInfoBeanPostProcessor.class);
initializeBatchJobInfo();
registerJobBean();
}
private void initializeBatchJobInfo() {
String beanName = applicationContext.getBeanNamesForType(this.getClass())[0];
this.batchJobInfoData = batchJobInfoProcessor.getBatchJobInfo(beanName);
}
/**
* 배치 작업을 Spring의 Bean으로 등록합니다.
*/
@@ -71,7 +81,7 @@ public abstract class AbstractBatchTask implements ApplicationContextAware {
var beanFactory = ((ConfigurableApplicationContext) applicationContext).getBeanFactory();
var registry = (BeanDefinitionRegistry) beanFactory;
var beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(Job.class, this::createJob);
registry.registerBeanDefinition(jobName(), beanDefinitionBuilder.getBeanDefinition());
registry.registerBeanDefinition(batchJobInfoData.getJobName(), beanDefinitionBuilder.getBeanDefinition());
}
/**
@@ -103,9 +113,9 @@ public abstract class AbstractBatchTask implements ApplicationContextAware {
private Job createJob() {
List<Step> steps = createSteps();
if (steps.isEmpty()) {
throw new IllegalStateException("No steps defined for job: " + jobName());
throw new IllegalStateException("No steps defined for job: " + batchJobInfoData.getJobName());
}
var jobBuilder = new JobBuilder(jobName())
var jobBuilder = new JobBuilder(batchJobInfoData.getJobName())
.incrementer(new RunIdIncrementer())
.repository(jobRepository)
.start(steps.get(0));
@@ -122,7 +132,7 @@ public abstract class AbstractBatchTask implements ApplicationContextAware {
*/
protected List<Step> createSteps() {
List<Step> steps = new ArrayList<>();
steps.add(addStep(jobName() + "Step", createTasklet()));
steps.add(addStep(batchJobInfoData.getJobName() + "Step", createTasklet()));
return steps;
}
@@ -148,40 +158,4 @@ public abstract class AbstractBatchTask implements ApplicationContextAware {
*/
protected abstract Tasklet createTasklet();
/**
* 배치 작업 그룹을 반환합니다.
*
* @return 배치 작업 그룹 이름
*/
public String group() {
return batchJobInfo.group();
}
/**
* 배치 작업 이름을 반환합니다.
*
* @return 배치 작업 이름
*/
public String jobName() {
return batchJobInfo.jobName();
}
/**
* cron 표현식을 반환합니다.
*
* @return cron 표현식
*/
public String cronExpression() {
return batchJobInfo.cronExpression();
}
/**
* 배치 작업의 설명을 반환합니다.
*
* @return 배치 작업의 설명
*/
public String description() {
return batchJobInfo.description();
}
}

View File

@@ -11,7 +11,7 @@ import java.lang.annotation.Target;
* 이 어노테이션은 배치 작업의 그룹, 이름 및 cron 표현식을 설정하는 데 사용됩니다.
* </p>
*/
@Target(ElementType.TYPE)
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface BatchJobInfo {
/**

View File

@@ -0,0 +1,79 @@
package com.spring.infra.batch;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.InstantiationAwareBeanPostProcessor;
import org.springframework.core.env.Environment;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class BatchJobInfoBeanPostProcessor implements InstantiationAwareBeanPostProcessor {
private final Environment environment;
private final Map<String, BatchJobInfoData> batchJobInfoMap = new ConcurrentHashMap<>();
private static final String BASE_PACKAGE = "com.spring";
@Override
@Nullable
public Object postProcessBeforeInstantiation(@NonNull Class<?> beanClass, @NonNull String beanName) throws BeansException {
if (beanClass.getPackage().getName().startsWith(BASE_PACKAGE)) {
processAnnotations(beanClass, beanName);
}
return null;
}
private void processAnnotations(Class<?> beanClass, String beanName) {
if (beanClass.isAnnotationPresent(BatchJobInfo.class)) {
BatchJobInfo batchJobInfo = beanClass.getAnnotation(BatchJobInfo.class);
setBatchJobInfoData(batchJobInfo, beanName);
}
ReflectionUtils.doWithMethods(beanClass, method -> {
if (method.isAnnotationPresent(BatchJobInfo.class)) {
BatchJobInfo batchJobInfo = method.getAnnotation(BatchJobInfo.class);
String jobBeanName = method.getName();
setBatchJobInfoData(batchJobInfo, jobBeanName);
}
});
}
private void setBatchJobInfoData(BatchJobInfo batchJobInfo, String beanName) {
batchJobInfoMap.put(beanName, processBatchJobInfo(batchJobInfo));
}
private BatchJobInfoData processBatchJobInfo(BatchJobInfo batchJobInfo) {
return new BatchJobInfoData(
resolvePlaceHolder(batchJobInfo.group()),
resolvePlaceHolder(batchJobInfo.jobName()),
resolvePlaceHolder(batchJobInfo.cronExpression()),
resolvePlaceHolder(batchJobInfo.description())
);
}
private String resolvePlaceHolder(String value) {
return environment.resolvePlaceholders(value);
}
public BatchJobInfoData getBatchJobInfo(String beanName) {
return batchJobInfoMap.get(beanName);
}
@Getter
@RequiredArgsConstructor
public static class BatchJobInfoData {
private final String jobGroup;
private final String jobName;
private final String cronExpression;
private final String description;
}
}

View File

@@ -77,7 +77,7 @@ public class QuartzConfig {
factory.setDataSource(dataSource);
factory.setTransactionManager(transactionManager);
factory.setJobFactory(jobFactory);
factory.setAutoStartup(false);
factory.setAutoStartup(true);
factory.setWaitForJobsToCompleteOnShutdown(true);
return factory;
}

View File

@@ -1,60 +0,0 @@
package com.spring.infra.quartz;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Quartz 스케줄러를 통해 실행될 작업을 정의하는 어노테이션입니다.
*
* <p>이 어노테이션은 메소드 레벨에서 사용되며, 해당 메소드를 Quartz 작업으로 등록합니다.</p>
*
* <p>주요 기능:</p>
* <ul>
* <li>작업의 이름 지정</li>
* <li>작업의 실행 주기를 Cron 표현식으로 정의</li>
* </ul>
*
* <p>사용 예:</p>
* <pre>
* {@code
* @QuartzJob(name = "myJob", cronExpression = "0 0 12 * * ?")
* public void myScheduledJob() {
* // 작업 내용
* }
* }
* </pre>
*
* @author mindol
* @version 1.0
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface QuartzJob {
/**
* Quartz 작업의 그룹을 지정합니다.
*
* @return 그룹의 이름
*/
String group() default "DEFAULT";
/**
* Quartz 작업의 이름을 지정합니다.
*
* @return 작업의 이름
*/
String jobName() default "";
/**
* 작업의 실행 주기를 Cron 표현식으로 지정합니다.
*
* @return Cron 표현식
*/
String cronExpression() default "";
/**
* Quartz 작업의 설명을 지정합니다.
*
* @return 작업의 설명
*/
String description() default "";
}

View File

@@ -1,6 +1,5 @@
package com.spring.infra.quartz;
import java.lang.reflect.Method;
import java.util.Map;
import org.quartz.CronScheduleBuilder;
@@ -14,11 +13,14 @@ import org.quartz.TriggerBuilder;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
import com.spring.infra.batch.AbstractBatchTask;
import com.spring.infra.batch.BatchJobInfo;
import com.spring.infra.batch.BatchJobInfoBeanPostProcessor;
import com.spring.infra.batch.BatchJobInfoBeanPostProcessor.BatchJobInfoData;
import lombok.RequiredArgsConstructor;
@@ -46,6 +48,7 @@ public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedE
private final Scheduler scheduler;
private final ApplicationContext applicationContext;
private final BatchJobInfoBeanPostProcessor batchJobInfoProcessor;
/**
* 애플리케이션 컨텍스트가 리프레시될 때 호출되는 메소드입니다.
@@ -56,8 +59,8 @@ public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedE
@Override
public void onApplicationEvent(@NonNull ContextRefreshedEvent event) {
clearJobs();
registerQuartzJobs();
registerAbstractTasks();
registerBatchJobs();
registerAbstractBatchJobs();
}
private void clearJobs() {
@@ -69,78 +72,81 @@ public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedE
}
/**
* QuartzJob 어노테이션이 붙은 모든 메소드를 찾아 Quartz 스케줄러에 등록합니다.
* BatchJobInfo 어노테이션이 붙은 모든 메소드를 찾아 Quartz 스케줄러에 등록합니다.
*/
private void registerQuartzJobs() {
private void registerBatchJobs() {
for (String beanName : applicationContext.getBeanDefinitionNames()) {
Object bean = applicationContext.getBean(beanName);
Class<?> beanClass = bean.getClass();
for (Method method : beanClass.getDeclaredMethods()) {
QuartzJob quartzJobAnnotation = AnnotationUtils.findAnnotation(method, QuartzJob.class);
if (quartzJobAnnotation != null) {
scheduleQuartzJob(quartzJobAnnotation);
ReflectionUtils.doWithMethods(beanClass, method -> {
if (method.isAnnotationPresent(BatchJobInfo.class)) {
BatchJobInfoData batchJobInfoData = batchJobInfoProcessor.getBatchJobInfo(beanName);
if (batchJobInfoData != null) {
scheduleQuartzJob(batchJobInfoData);
}
}
}
});
}
}
private void scheduleQuartzJob(QuartzJob quartzJobAnnotation) {
private void scheduleQuartzJob(BatchJobInfoData batchJobInfoData) {
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put("jobName", quartzJobAnnotation.jobName());
jobDataMap.put("jobName", batchJobInfoData.getJobName());
JobDetail jobDetail = JobBuilder.newJob(QuartzJobLauncher.class)
.withIdentity(quartzJobAnnotation.jobName(), quartzJobAnnotation.group())
.withIdentity(batchJobInfoData.getJobName(), batchJobInfoData.getJobGroup())
.setJobData(jobDataMap)
.storeDurably(true)
.withDescription(quartzJobAnnotation.description())
.withDescription(batchJobInfoData.getDescription())
.build();
CronTrigger trigger = TriggerBuilder.newTrigger()
.forJob(jobDetail)
.withIdentity(quartzJobAnnotation.jobName() + "Trigger", quartzJobAnnotation.group())
.withSchedule(CronScheduleBuilder.cronSchedule(quartzJobAnnotation.cronExpression()))
.withDescription(quartzJobAnnotation.description())
.withIdentity(batchJobInfoData.getJobName() + "Trigger", batchJobInfoData.getJobGroup())
.withSchedule(CronScheduleBuilder.cronSchedule(batchJobInfoData.getCronExpression()))
.withDescription(batchJobInfoData.getDescription())
.build();
try {
scheduler.scheduleJob(jobDetail, trigger);
} catch (SchedulerException e) {
throw new IllegalStateException("Error scheduling Quartz job: " + quartzJobAnnotation.jobName(), e);
throw new IllegalStateException("Error scheduling Quartz job: " + batchJobInfoData.getJobName(), e);
}
}
/**
* AbstractBatchJob을 상속받은 모든 클래스를 찾아 Quartz 스케줄러에 등록합니다.
*/
private void registerAbstractTasks() {
private void registerAbstractBatchJobs() {
Map<String, AbstractBatchTask> batchJobs = applicationContext.getBeansOfType(AbstractBatchTask.class);
for (AbstractBatchTask batchJob : batchJobs.values()) {
scheduleJob(batchJob);
for (String beanName : batchJobs.keySet()) {
scheduleJob(batchJobInfoProcessor.getBatchJobInfo(beanName));
}
}
private void scheduleJob(AbstractBatchTask batchJob) {
private void scheduleJob(BatchJobInfoData batchJobInfoData) {
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put("jobName", batchJob.jobName());
jobDataMap.put("jobName", batchJobInfoData.getJobName());
JobDetail jobDetail = JobBuilder.newJob(QuartzJobLauncher.class)
.withIdentity(batchJob.jobName(), batchJob.group())
.withIdentity(batchJobInfoData.getJobName(), batchJobInfoData.getJobGroup())
.setJobData(jobDataMap)
.storeDurably(true)
.withDescription(batchJob.description())
.withDescription(batchJobInfoData.getDescription())
.build();
CronTrigger trigger = TriggerBuilder.newTrigger()
.forJob(jobDetail)
.withIdentity(batchJob.jobName() + "Trigger", batchJob.group())
.withSchedule(CronScheduleBuilder.cronSchedule(batchJob.cronExpression()))
.withDescription(batchJob.description())
.withIdentity(batchJobInfoData.getJobName() + "Trigger", batchJobInfoData.getJobGroup())
.withSchedule(CronScheduleBuilder.cronSchedule(batchJobInfoData.getCronExpression()))
.withDescription(batchJobInfoData.getDescription())
.build();
try {
scheduler.scheduleJob(jobDetail, trigger);
} catch (SchedulerException e) {
throw new IllegalStateException("Error scheduling AbstractBatchJob: " + batchJob.jobName(), e);
throw new IllegalStateException("Error scheduling AbstractBatchJob: " + batchJobInfoData.getJobName(), e);
}
}

View File

@@ -0,0 +1,63 @@
package com.spring.infra.security.domain;
import java.util.Collection;
import java.util.Collections;
import org.springframework.security.core.GrantedAuthority;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class JwtUserPrincipal implements UserPrincipal {
private final String userId;
private final String userName;
@Override
public String getKey() {
return null;
}
@Override
public String getMemberName() {
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.emptyList();
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return userName;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

View File

@@ -0,0 +1,11 @@
package com.spring.infra.security.domain;
import org.springframework.security.core.userdetails.UserDetails;
public interface UserPrincipal extends UserDetails {
String getKey();
String getMemberName();
}

View File

@@ -55,14 +55,12 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter {
String requestURI = request.getRequestURI();
if (Arrays.stream(PermittedURI.values()).map(PermittedURI::getUri).anyMatch(uri -> pathMatcher.match(uri, requestURI)) &&
!PermittedURI.ROOT_URI.getUri().equals(requestURI))
{
!PermittedURI.ROOT_URI.getUri().equals(requestURI)) {
filterChain.doFilter(request, response);
return;
}
try {
String accessToken = parseBearerToken(request, HttpHeaders.AUTHORIZATION);
if (jwtTokenService.validateAccessToken(accessToken)) {
setAuthenticationToContext(accessToken);
@@ -71,11 +69,12 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter {
String refreshToken = jwtTokenService.resolveTokenFromCookie(request, JwtTokenRule.REFRESH_PREFIX);
jwtTokenService.validateToken(refreshToken);
String reissuedAccessToken = jwtTokenService.getRefreshToken(refreshToken);
Authentication authentication = jwtTokenService.getAuthentication(reissuedAccessToken);
jwtTokenService.saveRefreshToken(authentication.getName(), jwtTokenService.generateRefreshToken(response, authentication));
Authentication authentication = jwtTokenService.getAuthentication(refreshToken);
jwtTokenService.generateRefreshToken(response, authentication);
setAuthenticationToContext(jwtTokenService.generateAccessToken(response, authentication));
} catch (Exception e) {
jwtTokenService.deleteCookie(response);
request.setAttribute(EXCEPTION_ATTRIBUTE, e);
@@ -91,11 +90,11 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter {
* @param token 토큰
*/
private void setAuthenticationToContext(final String token) {
Authentication authentication = jwtTokenService.getJwtAuthentication(token);
Authentication authentication = jwtTokenService.getAccessAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private String parseBearerToken(HttpServletRequest request, String headerName) {
private String parseBearerToken(final HttpServletRequest request, final String headerName) {
return Optional.ofNullable(request.getHeader(headerName))
.filter(token -> token.substring(0, 7).equalsIgnoreCase(JwtTokenRule.BEARER_PREFIX.getValue()))
.map(token -> token.substring(7))

View File

@@ -6,6 +6,7 @@ import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import com.spring.infra.security.jwt.JwtTokenRule;
import com.spring.infra.security.jwt.JwtTokenService;
import lombok.RequiredArgsConstructor;
@@ -17,6 +18,8 @@ public class SignOutHandler implements LogoutHandler {
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String refreshToken = jwtTokenService.resolveTokenFromCookie(request, JwtTokenRule.REFRESH_PREFIX);
jwtTokenService.deleteRefreshToken(jwtTokenService.getUserPk(refreshToken));
jwtTokenService.deleteCookie(response);
}

View File

@@ -9,7 +9,6 @@ import javax.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
@@ -20,7 +19,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.spring.infra.security.config.SecurityURI;
import com.spring.infra.security.dto.SignResponse;
import com.spring.infra.security.jwt.JwtTokenService;
import com.spring.infra.security.service.RefreshTokenService;
import lombok.RequiredArgsConstructor;
@@ -29,7 +27,6 @@ import lombok.RequiredArgsConstructor;
public class SigninSuccessHandler implements AuthenticationSuccessHandler {
private final JwtTokenService jwtTokenService;
private final RefreshTokenService refreshTokenService;
private final ObjectMapper objectMapper;
private RequestCache requestCache = new HttpSessionRequestCache();
@@ -41,7 +38,7 @@ public class SigninSuccessHandler implements AuthenticationSuccessHandler {
) throws IOException, ServletException {
jwtTokenService.generateAccessToken(response, authentication);
refreshTokenService.saveRefreshToken((UserDetails) authentication.getPrincipal(), jwtTokenService.generateRefreshToken(response, authentication));
jwtTokenService.saveRefreshToken(authentication.getName(), jwtTokenService.generateRefreshToken(response, authentication));
SavedRequest savedRequest = requestCache.getRequest(request, response);
String targetUrl = (savedRequest != null) ? savedRequest.getRedirectUrl() : SecurityURI.REDIRECT_URI.getUri();

View File

@@ -11,6 +11,8 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import com.spring.infra.security.domain.UserPrincipal;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
@@ -36,10 +38,11 @@ public class JwtTokenGenerator {
* @return 생성된 액세스 토큰
*/
public String generateAccessToken(Authentication authentication) {
UserPrincipal user = (UserPrincipal) authentication.getPrincipal();
return Jwts.builder()
.setHeader(createHeader())
.setClaims(createClaims(authentication))
.setSubject(authentication.getName())
.setClaims(createClaims(user))
.setSubject(user.getUsername())
.setIssuedAt(Date.from(Instant.now()))
.setExpiration(Date.from(Instant.now().plus(jwtProperties.getAccessToken().getExpiration(), ChronoUnit.MINUTES)))
.signWith(jwtTokenUtil.getSigningKey(jwtProperties.getAccessToken().getSecret()))
@@ -53,8 +56,9 @@ public class JwtTokenGenerator {
* @return 생성된 리프레시 토큰
*/
public String generateRefreshToken(Authentication authentication) {
UserPrincipal user = (UserPrincipal) authentication.getPrincipal();
return Jwts.builder()
.setSubject(authentication.getName())
.setSubject(user.getKey())
.setIssuedAt(Date.from(Instant.now()))
.setExpiration(Date.from(Instant.now().plus(jwtProperties.getRefreshToken().getExpiration(), ChronoUnit.MINUTES)))
.signWith(jwtTokenUtil.getSigningKey(jwtProperties.getRefreshToken().getSecret()))
@@ -79,9 +83,11 @@ public class JwtTokenGenerator {
* @param authentication 인증 정보
* @return JWT 클레임 맵
*/
private Map<String, Object> createClaims(Authentication authentication) {
private Map<String, Object> createClaims(UserPrincipal user) {
Map<String, Object> claims = new HashMap<>();
claims.put(JwtTokenRule.AUTHORITIES_KEY.getValue(), authentication.getAuthorities().stream()
claims.put(JwtTokenRule.USER_ID.getValue(), user.getUsername());
claims.put(JwtTokenRule.USER_NAME.getValue(), user.getMemberName());
claims.put(JwtTokenRule.AUTHORITIES_KEY.getValue(), user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return claims;

View File

@@ -38,7 +38,17 @@ public enum JwtTokenRule {
/**
* JWT 클레임에서 권한 정보를 나타내는 키입니다.
*/
AUTHORITIES_KEY("auth");
AUTHORITIES_KEY("auth"),
/**
* JWT 클레임에서 사용자ID 정보를 나타내는 키입니다.
*/
USER_ID("userId"),
/**
* JWT 클레임에서 사용자명 정보를 나타내는 키입니다.
*/
USER_NAME("userName");
private final String value;

View File

@@ -14,12 +14,14 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.spring.domain.user.service.UserPrincipalService;
import com.spring.infra.security.domain.JwtUserPrincipal;
import com.spring.infra.security.error.SecurityAuthException;
import com.spring.infra.security.error.SecurityExceptionRule;
import com.spring.infra.security.service.RefreshTokenService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
@@ -37,7 +39,8 @@ public class JwtTokenService {
private final JwtTokenUtil jwtTokenUtil;
private final JwtTokenGenerator jwtTokenGenerator;
private final UserDetailsService userDetailsService;
private final UserPrincipalService userPrincipalService;
private final RefreshTokenService refreshTokenService;
private final Key accessSecretKey;
private final Key refreshSecretKey;
private final long refreshExpiration;
@@ -45,12 +48,14 @@ public class JwtTokenService {
public JwtTokenService(
JwtTokenUtil jwtTokenUtil,
JwtTokenGenerator jwtTokenGenerator,
UserDetailsService userDetailsService,
UserPrincipalService userPrincipalService,
RefreshTokenService refreshTokenService,
JwtProperties jwtProperties
) {
this.jwtTokenUtil = jwtTokenUtil;
this.jwtTokenGenerator = jwtTokenGenerator;
this.userDetailsService = userDetailsService;
this.userPrincipalService = userPrincipalService;
this.refreshTokenService = refreshTokenService;
this.accessSecretKey = jwtTokenUtil.getSigningKey(jwtProperties.getAccessToken().getSecret());
this.refreshSecretKey = jwtTokenUtil.getSigningKey(jwtProperties.getRefreshToken().getSecret());
this.refreshExpiration = jwtProperties.getRefreshToken().getExpiration();
@@ -153,28 +158,40 @@ public class JwtTokenService {
* @param token JWT 토큰
* @return 생성된 Authentication 객체
*/
public Authentication getJwtAuthentication(String token) {
Claims claims = getUserClaims(token);
String sub = claims.getSubject();
public Authentication getAccessAuthentication(String token) {
Claims claims = getUserClaims(token, accessSecretKey);
String userId = claims.get(JwtTokenRule.USER_ID.getValue(), String.class);
String userName = claims.get(JwtTokenRule.USER_NAME.getValue(), String.class);
List<SimpleGrantedAuthority> auths = claims.keySet().stream()
.filter(key -> key.equals(JwtTokenRule.AUTHORITIES_KEY.getValue()))
.flatMap(key -> ((List<?>) claims.get(key)).stream())
.map(String::valueOf)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new UsernamePasswordAuthenticationToken(sub, "", auths);
return new UsernamePasswordAuthenticationToken(new JwtUserPrincipal(userId, userName), "", auths);
}
/**
* 토큰으로부터 인증 정보를 가져와서 DB정보를 조회한다.
* JWT 토큰으로부터 인증 정보를 생성합니다.
*
* @param token JWT 토큰
* @return 생성된 Authentication 객체
*/
@Transactional(readOnly = true)
public Authentication getAuthentication(String token) {
UserDetails principal = userDetailsService.loadUserByUsername(getUserPk(token));
return new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities());
UserDetails user = userPrincipalService.getUser(getUserPk(token));
return new UsernamePasswordAuthenticationToken(user, "", null);
}
public void saveRefreshToken(String userId, String token) {
refreshTokenService.saveRefreshToken(userId, token);
}
public String getRefreshToken(String token) {
return refreshTokenService.getRefreshToken(getUserPk(token));
}
public void deleteRefreshToken(String key) {
refreshTokenService.deleteRefreshToken(key);
}
/**
@@ -183,9 +200,9 @@ public class JwtTokenService {
* @param token JWT 토큰
* @return 추출된 사용자 식별자
*/
private Claims getUserClaims(String token) {
private Claims getUserClaims(String token, Key key) {
return Jwts.parserBuilder()
.setSigningKey(accessSecretKey)
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
@@ -197,9 +214,9 @@ public class JwtTokenService {
* @param token JWT 토큰
* @return 추출된 사용자 식별자
*/
private String getUserPk(String token) {
public String getUserPk(String token) {
return Jwts.parserBuilder()
.setSigningKey(accessSecretKey)
.setSigningKey(refreshSecretKey)
.build()
.parseClaimsJws(token)
.getBody()

View File

@@ -4,12 +4,12 @@ import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import com.spring.infra.security.domain.UserPrincipal;
import com.spring.infra.security.error.SecurityAuthException;
import com.spring.infra.security.error.SecurityExceptionRule;
@@ -27,7 +27,7 @@ public class UserAuthenticationProvider implements AuthenticationProvider {
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
UserDetails user = userDetailsService.loadUserByUsername(username);
UserPrincipal user = (UserPrincipal) userDetailsService.loadUserByUsername(username);
if (isNotMatches(password, user.getPassword())) {
throw new SecurityAuthException(SecurityExceptionRule.USER_NOT_PASSWORD.getMessage());
}

View File

@@ -1,11 +1,11 @@
package com.spring.infra.security.service;
import org.springframework.security.core.userdetails.UserDetails;
public interface RefreshTokenService {
void saveRefreshToken(UserDetails user, String refreshToken);
void saveRefreshToken(String userId, String refreshToken);
String findByRefreshToken(String refreshToken);
String getRefreshToken(String refreshToken);
void deleteRefreshToken(String key);
}

View File

@@ -0,0 +1,32 @@
package com.spring.web.advice;
import java.util.HashMap;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import com.spring.infra.security.domain.JwtUserPrincipal;
import com.spring.infra.security.domain.UserPrincipal;
@ControllerAdvice(basePackages = "com.spring.web.controller")
public class GlobalControllerAdvice {
@ModelAttribute("userInfo")
public Map<String, String> userInfo() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Map<String, String> userInfo = new HashMap<>();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof UserPrincipal) {
JwtUserPrincipal user = (JwtUserPrincipal) auth.getPrincipal();
userInfo.put("userId", user.getUserId());
userInfo.put("userName", user.getUsername());
} else {
userInfo.put("userId", "");
userInfo.put("userName", "");
}
return userInfo;
}
}

View File

@@ -1,6 +1,5 @@
package com.spring.web.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -11,17 +10,9 @@ import com.spring.domain.user.entity.AgentUserRole;
@Controller
@RequestMapping("/")
public class SignController {
@Value("${front.base.url}")
private String baseUrl;
@Value("${front.base.timeout}")
private int timeout;
@GetMapping
public String signIn(Model model) {
model.addAttribute("baseUrl", baseUrl);
model.addAttribute("timeout", timeout);
model.addAttribute("roles", AgentUserRole.values());
return "pages/sign/sign-in";
}

View File

@@ -91,6 +91,23 @@ spring:
settings:
web-allow-others: true
batch-info:
email-send-batch:
group: "EMAIL"
job-name: "emailSendJob"
cron-expression: "*/10 * * * * ?"
description: "이메일배치작업"
post-batch:
group: "POST"
job-name: "postJob"
cron-expression: "0/20 * * * * ?"
description: "POST배치작업"
post-create-batch:
group: "POST"
job-name: "postCreateJob"
cron-expression: "0/50 * * * * ?"
description: "테스트배치작업"
jwt:
access-token:
secret: bnhjdXMyLjAtcGxhdGZvcm0tcHJvamVjdC13aXRoLXNwcmluZy1ib290bnhjdF9zdHJvbmdfY29tcGxleF9zdHJvbmc=
@@ -99,11 +116,6 @@ jwt:
secret: bnhjdXMyLjAtcGxhdGZvcm0tcHJvamVjdC13aXRoLXNwcmluZy1ib290bnhjdF9zdHJvbmdfY29tcGxleF9zdHJvbmc=
expiration: 10080
front:
base:
url: http://localhost:8081
timeout: 100000
logging:
level:
org:

View File

@@ -1,31 +1,15 @@
const baseUrl = window.BASE_URL || '';
const timeOut = window.TIME_OUT || 100000;
const setAuthorizationHeader = (token) => {
if (token) {
apiClient.defaults.headers['Authorization'] = `Bearer ${token}`;
} else {
delete apiClient.defaults.headers['Authorization'];
}
};
export const getAccessToken = () => localStorage.getItem('accessToken');
const getAccessToken = () => localStorage.getItem('accessToken');
export const saveAccessToken = (token) => {
localStorage.setItem('accessToken', token);
setAuthorizationHeader(token);
};
export const saveAccessToken = (token) => localStorage.setItem('accessToken', token);
export const removeTokens = () => {
localStorage.removeItem('accessToken');
setAuthorizationHeader(null);
location.href = "/";
};
// Axios apiClient 생성
const apiClient = axios.create({
baseURL: baseUrl,
timeout: timeOut,
timeout: 100000,
headers: {
'Content-Type': 'application/json',
},
@@ -36,7 +20,7 @@ apiClient.interceptors.request.use(
(config) => {
const token = getAccessToken();
if (token) {
setAuthorizationHeader(token);
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},

View File

@@ -4,7 +4,8 @@ import scheduleService from '../../apis/schedule-api.js';
document.addEventListener('DOMContentLoaded', () => {
fetchDataAndRender();
document.getElementById('searchForm').addEventListener('submit', async (e) => {
const searchForm = document.getElementById('searchForm');
searchForm.addEventListener('submit', async (e) => {
e.preventDefault();
fetchDataAndRender();
});

View File

@@ -3,9 +3,13 @@
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/css/bootstrap-icons.css}">
<link rel="stylesheet" th:href="@{/css/style.css}">
<script>
const BASE_URL = /*[[${baseUrl}]]*/ '';
const TIME_OUT = /*[[${timeout}]]*/ '';
<script th:inline="javascript">
/*<![CDATA[*/
const USER_INFO = {
userId: /*[[${userInfo?.userId ?: ''}]]*/ '',
userName: /*[[${userInfo?.userName ?: ''}]]*/ ''
};
/*]]>*/
</script>
<script th:src="@{/js/lib/axios/axios.min.js}"></script>
<script th:src="@{/js/lib/bootstrap/popper.min.js}"></script>

View File

@@ -2,7 +2,7 @@
<html xmlns:th="http://www.thymeleaf.org" th:fragment="header" lang="ko" xml:lang="ko">
<header id="header" class="header fixed-top d-flex align-items-center">
<div class="d-flex align-items-center justify-content-between">
<a href="index.html" class="logo d-flex align-items-center">
<a href="/" class="logo d-flex align-items-center">
<span class="d-none d-lg-block">NXCUS - Agent2.0</span>
</a>
<button class="btn toggle-sidebar-btn" id="toggleSidebar" aria-label="Toggle Sidebar">
@@ -14,7 +14,7 @@
<li class="nav-item dropdown pe-3">
<button class="nav-link nav-profile d-flex align-items-center pe-0 dropdown-toggle" id="profileDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle"></i>
<span class="d-none d-md-block ps-2">K. Anderson</span>
<span class="d-none d-md-block ps-2" th:text="${userInfo.userName}"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end w-auto" aria-labelledby="profileDropdown">
<li>