This commit is contained in:
mindol1004
2024-10-18 13:03:05 +09:00
parent 6dfa661394
commit 4baccedad5
36 changed files with 422 additions and 274 deletions

View File

@@ -7,6 +7,7 @@ import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
@@ -17,9 +18,11 @@ public class GlobalErrorController implements ErrorController {
@GetMapping("/error")
public String handleError(HttpServletRequest request, Model model) {
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
String errorMessage = String.valueOf(request.getAttribute(RequestDispatcher.ERROR_MESSAGE));
String statusMsg = status.toString();
HttpStatus httpStatus = HttpStatus.valueOf(Integer.valueOf(statusMsg));
model.addAttribute("message", statusMsg + " " + httpStatus.getReasonPhrase());
if (StringUtils.hasText(errorMessage)) model.addAttribute("errorMessage", errorMessage);
return "pages/error/error";
}

View File

@@ -1,4 +1,4 @@
package com.spring.common.advice;
package com.spring.common.support;
import java.time.LocalDateTime;

View File

@@ -1,4 +1,4 @@
package com.spring.common.advice;
package com.spring.common.support;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
@@ -8,6 +8,9 @@ import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
@@ -15,6 +18,7 @@ import com.spring.common.converter.CommHttpMessageConverter;
import com.spring.common.error.ErrorResponse;
import com.spring.common.error.ErrorRule;
import com.spring.common.error.GlobalExceptionHandler;
import com.spring.common.validation.CollectionValidator;
import com.spring.infra.security.error.SecurityExceptionHandler;
@RestControllerAdvice(
@@ -23,6 +27,17 @@ import com.spring.infra.security.error.SecurityExceptionHandler;
)
public class ResponseWrapper implements ResponseBodyAdvice<Object> {
private final LocalValidatorFactoryBean validator;
public ResponseWrapper(LocalValidatorFactoryBean validator) {
this.validator = validator;
}
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.addValidators(new CollectionValidator(validator));
}
@Override
public boolean supports(
@NonNull MethodParameter returnType,

View File

@@ -0,0 +1,32 @@
package com.spring.common.validation;
import java.util.Collection;
import org.springframework.lang.NonNull;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
public class CollectionValidator implements Validator {
private final Validator validator;
public CollectionValidator(LocalValidatorFactoryBean validatorFactory) {
this.validator = validatorFactory;
}
@Override
public boolean supports(@NonNull Class<?> clazz) {
return true;
}
@Override
public void validate(@NonNull Object target, @NonNull Errors errors) {
if (target instanceof Collection<?>) {
Collection<?> collection = (Collection<?>) target;
for (Object object : collection) {
ValidationUtils.invokeValidator(validator, object, errors);
}
}
}
}

View File

@@ -4,6 +4,7 @@ import java.util.List;
import javax.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -30,31 +31,37 @@ public class ScheduleJobApi {
private final ScheduleControlService scheduleControlService;
@GetMapping
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public List<ScheduleJobResponse> getAllJobs(@RequestParam(required = false) String groupName, @RequestParam(required = false) String jobName) {
return findScheduleJobService.getAllJobs(groupName, jobName);
}
@GetMapping("/{groupName}/{jobName}")
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public ScheduleJobResponse getJobDetail(@PathVariable String groupName, @PathVariable String jobName) {
return findScheduleJobService.getJobDetail(groupName, jobName);
}
@GetMapping("/pause/{groupName}/{jobName}")
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public void pauseJob(@PathVariable String groupName, @PathVariable String jobName) {
scheduleControlService.pauseJob(groupName, jobName);
}
@GetMapping("/resume/{groupName}/{jobName}")
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public void resumeJob(@PathVariable String groupName, @PathVariable String jobName) {
scheduleControlService.resumeJob(groupName, jobName);
}
@GetMapping("/trigger/{groupName}/{jobName}")
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public void triggerJob(@PathVariable String groupName, @PathVariable String jobName) {
scheduleControlService.triggerJob(groupName, jobName);
}
@PostMapping("/reschedule")
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public boolean rescheduleJob(@Valid @RequestBody ReScheduleJobRequest request) {
return reScheduleJobService.rescheduleJob(request);
}

View File

@@ -4,7 +4,10 @@ import java.util.List;
import javax.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -25,13 +28,21 @@ public class UserManagementApi {
private final UserManagementService userManagementService;
@GetMapping
@PreAuthorize("hasAnyRole('SUPER')")
public List<UserManagementResponse> getUsers(UserFindRequest request) {
return userManagementService.getUsers(request);
}
@PutMapping("/change-role-approve")
@PreAuthorize("hasAnyRole('SUPER')")
public void changeRoleApprove(@RequestBody @Valid List<ChangeUserRoleApproveRequest> requests) {
userManagementService.changeRoleApprove(requests);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAnyRole('SUPER')")
public void deleteUser(@PathVariable String id) {
userManagementService.deleteUser(id);
}
}

View File

@@ -20,6 +20,6 @@ public class ChangeUserRoleApproveRequest {
private final AgentUserRole userRole;
@NotNull(message = "승인 여부는 필수값 입니다.")
private final boolean isApproved;
private final boolean approved;
}

View File

@@ -38,10 +38,10 @@ public class SignUpRequest {
public AgentUser toEntity() {
return AgentUser.builder()
.userId(userId)
.userPassword(userPassword)
.userName(userName)
.password(userPassword)
.name(userName)
.email(email)
.isApproved(false)
.approved(false)
.userRole(userRole)
.build();
}

View File

@@ -26,7 +26,7 @@ public class UserFindRequest {
}
public static boolean matchesUserName(AgentUser user, String userName) {
return userName == null || userName.isEmpty() || user.getMemberName().contains(userName);
return userName == null || userName.isEmpty() || user.getName().contains(userName);
}
public static boolean matchesEmail(AgentUser user, String email) {

View File

@@ -21,7 +21,7 @@ public class UserManagementResponse {
return new UserManagementResponse(
user.getId().toString(),
user.getUserId(),
user.getMemberName(),
user.getName(),
user.getEmail(),
user.isApproved(),
user.getUserRole()

View File

@@ -1,9 +1,6 @@
package com.spring.domain.user.entity;
import java.util.Arrays;
import java.util.Collection;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.persistence.Column;
import javax.persistence.Entity;
@@ -14,10 +11,6 @@ import javax.persistence.Id;
import javax.persistence.Table;
import org.hibernate.annotations.GenericGenerator;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import com.spring.infra.security.domain.UserPrincipal;
import lombok.AccessLevel;
import lombok.Builder;
@@ -28,7 +21,7 @@ import lombok.NoArgsConstructor;
@Table(name = "AGENT_USER")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AgentUser implements UserPrincipal {
public class AgentUser {
@Id
@GeneratedValue(generator = "uuid2")
@@ -39,91 +32,42 @@ public class AgentUser implements UserPrincipal {
@Column(name = "USER_ID", nullable = false, length = 50)
private String userId;
@Column(name = "USER_PASSWORD", nullable = false, length = 128)
private String userPassword;
@Column(name = "PASSWORD", nullable = false, length = 128)
private String password;
@Column(name = "USER_NAME", nullable = false, length = 50)
private String userName;
@Column(name = "NAME", nullable = false, length = 50)
private String name;
@Column(name = "EMAIL", nullable = false, length = 100)
private String email;
@Column(name = "IS_APPROVED", nullable = false)
private boolean isApproved;
@Column(name = "APPROVED", nullable = false)
private boolean approved;
@Enumerated(EnumType.STRING)
@Column(name = "USER_ROLE", nullable = false, length = 50)
private AgentUserRole userRole;
@Builder
public AgentUser(String userId, String userPassword, String userName, AgentUserRole userRole, String email, boolean isApproved) {
public AgentUser(String userId, String password, String name, AgentUserRole userRole, String email, boolean approved) {
this.userId = userId;
this.userPassword = userPassword;
this.userName = userName;
this.password = password;
this.name = name;
this.userRole = userRole;
this.email = email;
this.isApproved = isApproved;
this.approved = approved;
}
public void changePassword(String newPassword) {
this.userPassword = newPassword;
this.password = newPassword;
}
public void changeUserRole(AgentUserRole userRole) {
this.userRole = userRole;
}
public void changeApproved(boolean isApproved) {
this.isApproved = isApproved;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.stream(AgentUserRole.values())
.filter(role -> Arrays.asList(this.userRole).contains(role))
.map(AgentUserRole::getRole)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public String getPassword() {
return this.userPassword;
}
@Override
public String getUsername() {
return this.userId;
}
@Override
public String getKey() {
return this.id.toString();
}
@Override
public String getMemberName() {
return this.userName;
public void changeApproved(boolean approved) {
this.approved = approved;
}
}

View File

@@ -40,4 +40,11 @@ public class UserManagementService {
}
}
@Transactional
public void deleteUser(String id) {
AgentUser user = agentUserRepository.findById(UUID.fromString(id))
.orElseThrow(UserNotFoundException::new);
agentUserRepository.delete(user);
}
}

View File

@@ -25,15 +25,18 @@ public class UserPrincipalService implements UserDetailsService, UserAuthenticat
@Transactional(readOnly = true)
@Override
public UserPrincipal loadUserByUsername(String username) throws UsernameNotFoundException {
return agentUserRepository.findByUserId(username)
.orElseThrow(() -> new SecurityAuthException(SecurityExceptionRule.USER_UNAUTHORIZED));
return UserPrincipal.valueOf(
agentUserRepository.findByUserId(username)
.orElseThrow(() -> new SecurityAuthException(SecurityExceptionRule.USER_UNAUTHORIZED))
);
}
@Transactional(readOnly = true)
@Override
public UserPrincipal getUserDetails(String key) {
return agentUserRepository.findById(UUID.fromString(key))
.orElseThrow(UserNotFoundException::new);
return UserPrincipal.valueOf(
agentUserRepository.findById(UUID.fromString(key)).orElseThrow(UserNotFoundException::new)
);
}
}

View File

@@ -12,6 +12,7 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.AnonymousConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig;
@@ -71,6 +72,7 @@ public class SecurityConfig {
.csrf(CsrfConfigurer::disable)
.httpBasic(HttpBasicConfigurer::disable)
.formLogin(FormLoginConfigurer::disable)
.anonymous(AnonymousConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.antMatchers(Arrays.stream(PermittedURI.values()).map(PermittedURI::getUri).toArray(String[]::new)).permitAll()
.anyRequest().authenticated()

View File

@@ -1,63 +0,0 @@
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

@@ -1,11 +1,78 @@
package com.spring.infra.security.domain;
import java.util.Arrays;
import java.util.Collection;
import java.util.stream.Collectors;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public interface UserPrincipal extends UserDetails {
import com.spring.domain.user.entity.AgentUser;
import com.spring.domain.user.entity.AgentUserRole;
String getKey();
import lombok.Getter;
import lombok.RequiredArgsConstructor;
String getMemberName();
@Getter
@RequiredArgsConstructor
public class UserPrincipal implements UserDetails {
private final transient AgentUser agentUser;
public static UserPrincipal valueOf(AgentUser agentUser) {
return new UserPrincipal(agentUser);
}
public static UserPrincipal of(String userId, String userName) {
return new UserPrincipal(AgentUser.builder().userId(userId).name(userName).build());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.stream(AgentUserRole.values())
.filter(role -> Arrays.asList(agentUser.getUserRole()).contains(role))
.map(AgentUserRole::getRole)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public String getPassword() {
return agentUser.getPassword();
}
@Override
public String getUsername() {
return agentUser.getUserId();
}
public String getKey() {
return agentUser.getId().toString();
}
public String getMemberName() {
return agentUser.getName();
}
}

View File

@@ -9,6 +9,11 @@ public class SecurityAuthException extends AuthenticationException {
private final SecurityExceptionRule exceptionRule;
public SecurityAuthException() {
super(SecurityExceptionRule.SYSTEM_ERROR.getMessage());
this.exceptionRule = SecurityExceptionRule.SYSTEM_ERROR;
}
public SecurityAuthException(String msg) {
super(msg);
this.exceptionRule = SecurityExceptionRule.SYSTEM_ERROR;

View File

@@ -1,27 +1,33 @@
package com.spring.infra.security.error;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.SignatureException;
import lombok.RequiredArgsConstructor;
@RestControllerAdvice
@RequiredArgsConstructor
public class SecurityExceptionHandler {
private final AccessDeniedHandler accessDeniedHandler;
@ExceptionHandler(SecurityAuthException.class)
public SecurityErrorResponse handleAuthenticationException(SecurityAuthException e) {
return SecurityErrorResponse.valueOf(e.getExceptionRule());
}
@ExceptionHandler(AccessDeniedException.class)
public SecurityErrorResponse handleAccessDeniedException() {
return SecurityErrorResponse.valueOf(SecurityExceptionRule.USER_FORBIDDEN);
}
@ExceptionHandler(AuthenticationException.class)
public SecurityErrorResponse handleAuthenticationException() {
return SecurityErrorResponse.valueOf(SecurityExceptionRule.USER_UNAUTHORIZED);
@@ -42,4 +48,11 @@ public class SecurityExceptionHandler {
return SecurityErrorResponse.valueOf(SecurityExceptionRule.EXPIRED_JWT_ERROR);
}
@ExceptionHandler(AccessDeniedException.class)
public void handleAccessDeniedException(
AccessDeniedException ex, HttpServletRequest request, HttpServletResponse response
) throws IOException, ServletException {
accessDeniedHandler.handle(request, response, ex);
}
}

View File

@@ -17,18 +17,17 @@ import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.spring.domain.user.entity.AgentUser;
import com.spring.infra.security.config.PermittedURI;
import com.spring.infra.security.domain.UserPrincipal;
import com.spring.infra.security.dto.SignInRequest;
import com.spring.infra.security.error.SecurityAuthException;
import com.spring.infra.security.error.SecurityExceptionRule;
public class AuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_LOGIN_REQUEST_URL = PermittedURI.USER_SIGN_IN.getUri();
private static final String DEFAULT_LOGIN_REQUEST_URI = PermittedURI.USER_SIGN_IN.getUri();
private static final String HTTP_METHOD = "POST";
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD);
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER = new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URI, HTTP_METHOD);
private final ObjectMapper objectMapper;
@@ -38,8 +37,11 @@ public class AuthenticationProcessingFilter extends AbstractAuthenticationProces
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException, IOException, ServletException {
if (!isValidRequestType(request)) {
throw new SecurityAuthException(SecurityExceptionRule.UNSUPPORTED_MEDIA_ERROR);
}
@@ -49,9 +51,14 @@ public class AuthenticationProcessingFilter extends AbstractAuthenticationProces
}
var token = new UsernamePasswordAuthenticationToken(signInRequest.getUsername(), signInRequest.getPassword());
var authentication = this.getAuthenticationManager().authenticate(token);
if (!((AgentUser) authentication.getPrincipal()).isApproved()) {
if (authentication.getPrincipal() instanceof UserPrincipal) {
UserPrincipal user = (UserPrincipal) authentication.getPrincipal();
if (!user.getAgentUser().isApproved()) {
throw new SecurityAuthException(SecurityExceptionRule.USER_NOT_APPROVED);
}
}
return authentication;
}

View File

@@ -13,7 +13,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.lang.NonNull;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import com.spring.infra.security.config.PermittedURI;
@@ -33,7 +32,6 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public final class JwtAuthenticationFilter extends OncePerRequestFilter {
private final AntPathMatcher pathMatcher = new AntPathMatcher();
private final JwtTokenService jwtTokenService;
private static final String EXCEPTION_ATTRIBUTE = "exception";
@@ -53,9 +51,7 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter {
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String requestURI = request.getRequestURI();
if (Arrays.stream(PermittedURI.values()).map(PermittedURI::getUri).anyMatch(uri -> pathMatcher.match(uri, requestURI)) &&
!PermittedURI.ROOT_URI.getUri().equals(requestURI)) {
if (isPermittedURI(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
@@ -64,24 +60,21 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter {
String accessToken = parseBearerToken(request, HttpHeaders.AUTHORIZATION);
if (jwtTokenService.validateAccessToken(accessToken)) {
setAuthenticationToContext(accessToken);
return;
}
} else {
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));
setAuthenticationToContext(jwtTokenService.generateAccessToken(response, authentication));
}
} catch (Exception e) {
jwtTokenService.deleteCookie(response);
request.setAttribute(EXCEPTION_ATTRIBUTE, e);
} finally {
filterChain.doFilter(request, response);
}
filterChain.doFilter(request, response);
}
/**
@@ -101,4 +94,11 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter {
.orElse(jwtTokenService.resolveTokenFromCookie(request, JwtTokenRule.ACCESS_PREFIX));
}
private boolean isPermittedURI(String requestURI) {
return Arrays.stream(PermittedURI.values())
.map(PermittedURI::getUri)
.anyMatch(uri -> uri.equals(requestURI)) &&
!PermittedURI.ROOT_URI.getUri().equals(requestURI);
}
}

View File

@@ -2,16 +2,24 @@ package com.spring.infra.security.handler;
import java.io.IOException;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerExceptionResolver;
import com.spring.infra.security.error.SecurityAuthException;
import com.spring.infra.security.error.SecurityExceptionRule;
import lombok.RequiredArgsConstructor;
/**
* JWT 인증에서 접근 거부 상황을 처리하는 핸들러 클래스입니다.
*
@@ -22,30 +30,29 @@ import org.springframework.web.servlet.HandlerExceptionResolver;
* @version 1.0
*/
@Component
@RequiredArgsConstructor
public class SecurityAccessDeniedHandler implements AccessDeniedHandler {
private final HandlerExceptionResolver resolver;
private final ApplicationContext applicationContext;
public SecurityAccessDeniedHandler(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
/**
* 접근 거부 상황을 처리합니다.
*
* <p>사용자가 접근 권한이 없는 리소스에 접근을 시도할 때 호출됩니다.
* 이 메소드는 SC_FORBIDDEN (403) 상태 코드를 응답으로 전송합니다.</p>
*
* @param request 현재 HTTP 요청
* @param response 현재 HTTP 응답
* @param accessDeniedException 발생한 접근 거부 예외
* @throws IOException 입출력 예외 발생 시
* @throws ServletException 서블릿 예외 발생 시
*/
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
resolver.resolveException(request, response, null, accessDeniedException);
if (isApiRequest(request)) {
HandlerExceptionResolver resolver = applicationContext.getBean("handlerExceptionResolver", HandlerExceptionResolver.class);
resolver.resolveException(request, response, null, new SecurityAuthException(SecurityExceptionRule.USER_FORBIDDEN));
} else {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE, HttpServletResponse.SC_FORBIDDEN);
request.setAttribute(RequestDispatcher.ERROR_MESSAGE, SecurityExceptionRule.USER_FORBIDDEN.getMessage());
RequestDispatcher dispatcher = request.getRequestDispatcher("/error");
dispatcher.forward(request, response);
}
}
private boolean isApiRequest(HttpServletRequest request) {
String accept = request.getHeader(HttpHeaders.ACCEPT);
return accept != null && accept.contains(MediaType.APPLICATION_JSON_VALUE);
}
}

View File

@@ -35,8 +35,7 @@ public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoin
private final HttpRequestEndpointChecker endpointChecker;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
if (!endpointChecker.isEndpointExist(request)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
} else if (isApiRequest(request)) {

View File

@@ -12,15 +12,15 @@ import org.springframework.security.web.authentication.AuthenticationFailureHand
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerExceptionResolver;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class SigninFailureHandler implements AuthenticationFailureHandler {
@Qualifier("handlerExceptionResolver")
private final HandlerExceptionResolver resolver;
public SigninFailureHandler(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void onAuthenticationFailure(
HttpServletRequest request,

View File

@@ -18,7 +18,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.spring.infra.security.domain.JwtUserPrincipal;
import com.spring.infra.security.domain.UserPrincipal;
import com.spring.infra.security.error.SecurityAuthException;
import com.spring.infra.security.error.SecurityExceptionRule;
import com.spring.infra.security.service.RefreshTokenService;
@@ -173,7 +173,7 @@ public class JwtTokenService {
.map(String::valueOf)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new UsernamePasswordAuthenticationToken(new JwtUserPrincipal(userId, userName), "", auths);
return new UsernamePasswordAuthenticationToken(UserPrincipal.of(userId, userName), "", auths);
}
/**

View File

@@ -1,32 +0,0 @@
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

@@ -0,0 +1,46 @@
package com.spring.web.constant;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import com.spring.domain.user.entity.AgentUserRole;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Menus {
DASHBOARD(
"/dashboard",
"Dashboard",
"bi bi-grid",
List.of(AgentUserRole.ROLE_SUPER, AgentUserRole.ROLE_ADMIN, AgentUserRole.ROLE_USER)
),
SCHEDULE(
"/schedule",
"Schedule",
"bi bi-menu-button-wide",
List.of(AgentUserRole.ROLE_SUPER, AgentUserRole.ROLE_ADMIN)
),
USER_MANAGEMENT(
"/user/management",
"User Management",
"bi bi-person",
List.of(AgentUserRole.ROLE_SUPER)
);
private final String menuUri;
private final String menuName;
private final String menuIcon;
private final List<AgentUserRole> roles;
public static List<Menus> fromRole(AgentUserRole role) {
return Arrays.stream(values())
.filter(menu -> menu.roles.contains(role))
.collect(Collectors.toList());
}
}

View File

@@ -1,5 +1,6 @@
package com.spring.web.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -9,6 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
public class ScheduleController {
@GetMapping
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public String schedule() {
return "pages/schedule/schedule";
}

View File

@@ -1,8 +1,6 @@
package com.spring.web.controller;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -15,11 +13,9 @@ import com.spring.domain.user.entity.AgentUserRole;
public class UserController {
@GetMapping("/management")
@PreAuthorize("hasAnyRole('SUPER')")
public String management(Model model) {
List<AgentUserRole> roles = List.of(AgentUserRole.values()).stream()
.filter(role -> !role.getRole().equals(AgentUserRole.ROLE_SUPER.name()))
.collect(Collectors.toList());
model.addAttribute("roles", roles);
model.addAttribute("roles", AgentUserRole.values());
return "pages/user/user-management";
}

View File

@@ -0,0 +1,47 @@
package com.spring.web.support;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
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.domain.user.entity.AgentUserRole;
import com.spring.infra.security.domain.UserPrincipal;
import com.spring.web.constant.Menus;
@ControllerAdvice(basePackages = "com.spring.web.controller")
public class GlobalControllerAdvice {
@ModelAttribute("userInfo")
public Map<String, String> userInfo() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof UserPrincipal) {
UserPrincipal user = (UserPrincipal) auth.getPrincipal();
return Map.of("userId", user.getUsername(), "userName", user.getMemberName());
}
return Map.of("userId", "", "userName", "");
}
@ModelAttribute("menus")
public List<Map<String, String>> getMenus() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()) {
AgentUserRole userRole = auth.getAuthorities().stream()
.map(role -> role.getAuthority())
.map(AgentUserRole::fromRole)
.findFirst()
.orElse(AgentUserRole.ROLE_USER);
return Menus.fromRole(userRole).stream()
.map(menu -> Map.of("menuUri", menu.getMenuUri(), "menuName", menu.getMenuName(), "menuIcon", menu.getMenuIcon()))
.collect(Collectors.toList());
}
return Collections.emptyList();
}
}

View File

@@ -10,8 +10,8 @@ const userService = {
await apiClient.put('/api/user/change-role-approve', users);
},
deleteUser: async (userId) => {
await apiClient.delete(`/api/user/${userId}`);
deleteUser: async (id) => {
await apiClient.delete(`/api/user/${id}`);
}
};

View File

@@ -5,3 +5,20 @@ export const formatDateTime = (dateTimeString) => {
const date = new Date(dateTimeString);
return dayjs(date).format("YYYY-MM-DD ddd A HH:mm:ss");
}
export const getModifiedRows = (arr1, arr2, keyField) => {
const modifiedRows = [];
const map1 = new Map(arr1.map(item => [item[keyField], item]));
arr2.forEach(item2 => {
const item1 = map1.get(item2[keyField]);
if (item1 && !deepEqualSelectedFields(item1, item2)) {
modifiedRows.push(item2);
}
});
return modifiedRows;
}
export const deepEqualSelectedFields = (obj1, obj2) => {
const keysToCompare = Object.keys(obj2);
return keysToCompare.every(key => obj1[key] === obj2[key]);
}

View File

@@ -1,3 +1,4 @@
import { getModifiedRows } from '../../common/common.js';
import userService from '../../apis/user-api.js';
let users = [];
@@ -12,7 +13,12 @@ const setupEventListeners = () => {
e.preventDefault();
fetchDataAndRender();
});
document.getElementById('saveChangesBtn').addEventListener('click', saveChanges);
document.getElementById('updateUserBtn').addEventListener('click', () => {
const confirmUpdate = confirm('회원정보를 수정하시겠습니까?');
if (confirmUpdate) {
updateUser();
}
});
};
const fetchDataAndRender = async () => {
@@ -24,49 +30,58 @@ const fetchDataAndRender = async () => {
const updateTable = (users) => {
const tableBody = document.querySelector('tbody');
tableBody.innerHTML = users.map(user => `
<tr>
<tr data-key="${user.id}">
<td class="align-middle">${user.userId}</td>
<td class="align-middle">${user.userName}</td>
<td class="align-middle">${user.email}</td>
<td class="align-middle">
<select class="form-select form-select-sm" data-user-id="${user.id}" data-user-role="${user.userRole}">
<select id="userRole-${user.id}" class="form-select form-select-sm">
${ROLES.length > 0 ? ROLES.map(role => `
<option value="${role}" ${user.userRole === role ? 'selected' : ''}>${role}</option>
`).join('') : '<option value=""></option>'}
</select>
</td>
<td class="align-middle">
<input type="checkbox" ${user.approved ? 'checked' : ''} data-user-id="${user.id}" data-user-approved="${user.approved}" class="form-check-input">
<input id="approved-${user.id}" type="checkbox" ${user.approved ? 'checked' : ''} class="form-check-input">
</td>
<td class="align-middle">
<button id="deleteUserBtn" onclick="deleteUser('${user.id}')" class="btn btn-sm btn-outline-danger" data-bs-toggle="tooltip" data-bs-placement="left" title="사용자 삭제">
<button class="btn btn-sm btn-outline-danger delete-btn" data-id="${user.id}" title="사용자 삭제">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
document.querySelectorAll('.delete-btn').forEach(btn => btn.addEventListener('click', (e) => {
const confirmUpdate = confirm('회원정보를 삭제하시겠습니까?');
if (confirmUpdate) {
const {id} = e.target.closest('button').dataset;
deleteUser(id);
}
}));
};
const saveChanges = async () => {
const updatedUsers = users.map(user => {
const selectElement = document.querySelector(`select[data-user-id="${user.id}"]`);
const checkboxElement = document.querySelector(`input[data-user-approved="${user.approved}"]`);
const updateUser = async () => {
const updatedUsers = Array.from(document.querySelectorAll('tbody tr')).map(row => {
const id = row.dataset.key;
const selectElement = row.querySelector(`#userRole-${id}`);
const checkboxElement = row.querySelector(`#approved-${id}`);
const userRole = selectElement ? selectElement.value : null;
const isApproved = checkboxElement ? checkboxElement.checked : false;
return {
id: user.id,
id: id,
userRole: userRole,
isApproved: isApproved
approved: isApproved
};
});
await userService.changeRoleApprove(updatedUsers);
await userService.changeRoleApprove(getModifiedRows(users, updatedUsers, "id"));
alert('회원정보가 수정 되었습니다.');
fetchDataAndRender();
};
const deleteUser = async (userId) => {
await userService.deleteUser(userId);
const deleteUser = async (id) => {
await userService.deleteUser(id);
alert('사용자가 삭제되었습니다.');
fetchDataAndRender();
};

View File

@@ -9,6 +9,7 @@
userId: /*[[${userInfo?.userId ?: ''}]]*/ '',
userName: /*[[${userInfo?.userName ?: ''}]]*/ ''
};
const MENUS = /*[[${menus}]]*/ '';
/*]]>*/
</script>
<script th:src="@{/js/lib/axios/axios.min.js}"></script>

View File

@@ -2,14 +2,11 @@
<html xmlns:th="http://www.thymeleaf.org" th:fragment="sidebar" lang="ko" xml:lang="ko">
<aside id="sidebar" class="sidebar">
<ul class="sidebar-nav" id="sidebar-nav">
<li class="nav-item">
<a class="nav-link" href="/dashboard"><i class="bi bi-grid"></i><span>Dashboard</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="/schedule"><i class="bi bi-menu-button-wide"></i><span>Schedule</span></a>
</li>
<li class="nav-item"></li>
<a class="nav-link" href="/user/management"><i class="bi bi-person"></i><span>User Management</span></a>
<li class="nav-item" th:each="menu : ${menus}">
<a class="nav-link" th:href="${menu.menuUri}">
<i th:class="${menu.menuIcon}"></i>
<span th:text="${menu.menuName}"></span>
</a>
</li>
</ul>
</aside>

View File

@@ -15,7 +15,7 @@
</div>
<h1 class="display-4 fw-bold text-danger mb-4" th:text="${#strings.substring(message, 0, 3)}">500</h1>
<h2 class="h4 text-secondary mb-4" th:text="${#strings.substring(message, 4)}">내부 서버 오류</h2>
<p class="text-muted mb-4">죄송합니다. 문제가 발생했습니다. 기술팀이 이 문제를 해결하기 위해 노력하고 있습니다.</p>
<p class="text-muted mb-4" th:text="${errorMessage}">죄송합니다. 문제가 발생했습니다. 기술팀이 이 문제를 해결하기 위해 노력하고 있습니다.</p>
<a href="/" class="btn btn-primary btn-lg d-inline-flex align-items-center">
<i class="bi bi-house-door-fill me-2"></i>홈으로 돌아가기
</a>

View File

@@ -79,7 +79,7 @@
<div class="card-body">
<h5 class="card-title d-flex justify-content-between align-items-center">
<span class="fs-6 text-dark"><i class="bi bi-list-ul"></i> 사용자 목록</span>
<button id="saveChangesBtn" class="btn btn-sm btn-outline-primary" data-bs-toggle="tooltip" data-bs-placement="left" title="사용자 수정">
<button id="updateUserBtn" class="btn btn-sm btn-outline-primary" data-bs-toggle="tooltip" data-bs-placement="left" title="사용자 수정">
<i class="bi bi-pencil-fill"></i>
</button>
</h5>