Role 사용을 해보자

내 프로젝트의 경우 ADD버튼이 admin으로 로그인했을 때만 나타나야 한다.

 

  • Role
@Getter
@AllArgsConstructor
public enum Role {
  ROLE_ADMIN("관리자", RoleBase.ADMINISTRATOR),
  ROLE_USER("사용자", RoleBase.USER);

  private String text;
  private RoleBase roleBase;

  public static List<Role> findByRoleBase(RoleBase roleBase) {
    return Arrays.stream(Role.values()).filter(p -> p.getRoleBase().equals(roleBase))
        .collect(Collectors.toList());
  }
}

일단 role은 ROLE_ADMIN과 ROLE_USER로 설정

DB에 admin 계정을 하나 넣어준다.(이를 처음 실행할 때 자동으로 넣어주도록 data.sql 파일을 생성해 넣어줘야 하는데 아직 구현하지 않았다.. 현재는 수동으로..!)

뒤에 User에 맞춰서 수동으로 넣어준다.

 

  • add버튼 관련
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<!--      admin-->
      <a class="addButton" th:text="|ADD|" th:href="|@{/project/edit}|" sec:authorize="hasRole('ROLE_ADMIN')"></a>

저렇게 설정하면 admin계정으로 로그인하면 add버튼이 보이고 logout 하면 보이지 않는다.

 

❗ 추가적으로 아예 관리자 대시보드를 만들려면 Controller에 어노테이션을 넣어준다. ❗

@Secured("ROLE_ADMIN")

그러면 해당 controller가 작동할 때는 admin계정으로 로그인했을 때이다.

login 구현

화면부터 구성해보자

  • /resources/templates/auth/login
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>login</title>
</head>
<body>
<form method="post" th:action="@{/auth/login/process}">
  <div style="padding: 30px"></div>
  <div style="padding: 15px">
    <input placeholder="username" id="username" name="username" type="text"/>
    <input placeholder="password" id="password" name="password" type="password"/>
    <button type="submit">login</button>
    <!--    로그인 실패 경고-->
    <div th:if="${error}" class="alert bg--error">
      <div class="alert__body">
        <span th:text="${error}" class="text-danger" style="color: #ff1034;"></span>
      </div>
    </div>
  </div>
  <div style="padding: 15px">
    <a th:href="@{/auth/find-account/username}">Forgot your Username or Password?</a>
    <span>&nbsp;&nbsp;|&nbsp;&nbsp;</span>
    <a th:href="@{/auth/register}">Sign up</a>
  </div>
</form>

</body>
</html>

 

  • AuthController : 화면과 연결
@Controller
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {

  @NonNull
  private final UserService userService;
  @NonNull
  private final SmartValidator validator;

  @RequestMapping("/login")
  public String login() {
    return "auth/login";
  }
  }

 

  • UserService
@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsService {

  @NonNull
  private final BCryptPasswordEncoder bCryptPasswordEncoder;
  @NonNull
  private final UserRepository userRepository;

  public Optional<User> findById(Integer id) {
    return userRepository.findById(id);
  }

  public Page<User> findAllByFilter(Pageable pageable, UserFilter filter) {
    return userRepository.findAllByFilter(pageable, filter);
  }

  public User save(User user) throws UserPasswordValidationException, DataNotFoundException {
    if (user.hasPasswordChanged()) {
      if (PasswordUtil.isValidPassword(user.getPassword())) {
        String encodedPassword = bCryptPasswordEncoder.encode(user.getPassword());
        user.setPassword(encodedPassword);
        user.setLastPasswordChangeDate(LocalDate.now());
      } else {
        throw new UserPasswordValidationException();
      }
    } else {
      user.setPassword(userRepository.findPasswordByUser(user));
    }
    return userRepository.save(user);
  }

  @Transactional
  public int removeByIdIn(List<Integer> ids) {
    return userRepository.removeByIdIn(ids);
  }

  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Optional<User> user = userRepository.findByUsername(username);
    if (!user.isPresent()) {
      throw new UsernameNotFoundException("일치하는 사용자를 찾을 수 없습니다.");
    }
    return user.get();
  }

  public boolean existsByUsername(String username) {
    return userRepository.existsByUsername(username);
  }

  public boolean existsByName(String name) {
    return userRepository.existsByName(name);
  }

  public Page<User> findAllByIdIn(List<Integer> ids, Pageable pageable) {
    return userRepository.findAllByIdIn(ids, pageable);
  }
}

❗ 나중에 활용할 함수들 ❗

 

  • UserRepository
//UserRepository
@Repository
public interface UserRepository extends JpaRepository<User, Integer>, CustomUserRepository {

  Optional<User> findByUsername(String username);

  boolean existsByUsername(String username);

  boolean existsByName(String name);

  @Query("SELECT p.password FROM User p WHERE p.id = :#{#user.id}")
  String findPasswordByUser(@Param("user") User user);

  @Modifying
  @Query("UPDATE User p SET p.removed = true, p.username = CONCAT(p.username, '-removed') WHERE p.id IN(:ids)")
  int removeByIdIn(@Param("ids") List<Integer> ids);

  Page<User> findAllByIdIn(List<Integer> ids, Pageable pageable);
}

//CustomUserRepository
@NoRepositoryBean
public interface CustomUserRepository {

  Page<User> findAllByFilter(Pageable pageable, UserFilter filter);

}

//UserRepositoryImpl
public class UserRepositoryImpl extends QuerydslRepositorySupport implements CustomUserRepository {

  private QUser user = QUser.user;

  public UserRepositoryImpl() {
    super(User.class);
  }

  @Override
  public Page<User> findAllByFilter(Pageable pageable, UserFilter filter) {
    BooleanBuilder builder = new BooleanBuilder();

    builder.and(user.removed.isFalse());

    if (filter.getFRole() != null) {
      builder.and(user.role.eq(filter.getFRole()));
    }

    if (!StringUtils.isBlank(filter.getFName())) {
      builder.and(user.name.containsIgnoreCase(filter.getFName()));
    }

    if (!StringUtils.isBlank(filter.getFUsername())) {
      builder.and(user.username.containsIgnoreCase(filter.getFUsername()));
    }

    if (filter.getFLocked() != null) {
      builder.and(user.locked.eq(filter.getFLocked()));
    }

    final JPQLQuery<User> query = from(user).where(builder);
    final List<User> result = getQuerydsl().applyPagination(pageable, query).fetch();
    return new PageImpl<>(result, pageable, query.fetchCount());
  }
}

 

  • UserFilter
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserFilter extends QueryStringSupport {

  private Role fRole;

  private String fUsername;

  private String fName;

  private Boolean fLocked;

}

 

    • QueryStringSupport
@Getter
@Setter
public class QueryStringSupport {

  protected String sort;
  protected Integer page;

  public String getQueryString() {
    try {
      List<String> queries = new ArrayList<>();
      List<Field> fieldList = FieldUtils.getAllFieldsList(this.getClass());
      for (Field f : fieldList) {
        String key = f.getName();
        Object value = FieldUtils.readField(f, this, true);

        if (value == null) {
          // querys.add(key + "=");
          continue;
        }

        if (value instanceof Collection) {
          Collection col = (Collection) value;
          for (Object o : col) {
            queries.add(key + "=" + new URLCodec().encode(o.toString()));
          }
        } else {
          queries.add(key + "=" + new URLCodec().encode(value.toString()));
        }
      }

      return Strings.join(queries, '&');
    } catch (IllegalAccessException | EncoderException e) {
      return "";
    }
  }

  public void fromQueryString(String queryString) {
    List<Field> fieldList = FieldUtils.getAllFieldsList(this.getClass());
    List<NameValuePair> nameValuePairs = URLEncodedUtils
        .parse(queryString, Charset.forName("UTF-8"));

    for (Field f : fieldList) {
      String fieldName = f.getName();

      for (NameValuePair p : nameValuePairs) {
        String paramName = p.getName();

        if (fieldName.equalsIgnoreCase(paramName)) {
          try {
            FieldUtils.writeField(f, this, p.getValue());
          } catch (IllegalAccessException e) {
            //DO Nothing
          }
          break;
        }
      }
    }
  }
}

 

위와 같이 코드를 상황에 맞게 설정하고 DB에 사용자를 추가해서 login해보기!

 

 

logout구현

<html xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

<a sec:authorize="isAnonymous()" th:href="@{/auth/login}" style="padding: 15px">login</a>
<a sec:authorize="isAuthenticated()" th:href="@{/auth/logout}" style="padding: 15px">logout</a>

isAnonymous() : 익명의 사용자

isAuthenticated() : 인증한 사용자

 

위와 같이 설정하면 로그인 하기 전에는 login a태그를 로그인에 성공하고 나면 logout a태그를 보여준다.

 

❗ 다음 포스팅은 register 관련 ❗

login을 위한 setting

  • pom.xml
<!--    security-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.thymeleaf.extras</groupId>
      <artifactId>thymeleaf-extras-springsecurity5</artifactId>
    </dependency>

 

  • domain-User
@Getter
@Setter
@Entity
@Table(name = "user", indexes = {@Index(columnList = "username"), @Index(columnList = "name"),
    @Index(columnList = "role"), @Index(columnList = "enabled"), @Index(columnList = "removed")})
@NoArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class User implements UserDetails {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @EqualsAndHashCode.Include
  private Integer id;

  @Column(nullable = false, insertable = false, updatable = false, columnDefinition = "datetime default CURRENT_TIMESTAMP")
  @DateTimeFormat(pattern = DateUtil.PATTERN_YMDHM)
  private LocalDateTime createDate;   //생성일시

  @Size(min = 2, max = 32)
  @Column(unique = true, nullable = false, updatable = false)
  private String username;

  @Size(min = 2, max = 8)
  @Column(nullable = false)
  private String name;

  @Column(nullable = false)
  @JsonIgnore
  private String password;

  @Enumerated(EnumType.STRING)
  @Column(nullable = false, updatable = false, columnDefinition = "varchar(50)")
  private Role role;

  @JsonIgnore
  @DateTimeFormat(pattern = DateUtil.PATTERN_YMD)
  @Column(columnDefinition = "date default CURRENT_TIMESTAMP")
  private LocalDate lastPasswordChangeDate = LocalDate.now();

  // 화면에서 추가인지 수정인지 여부를 보여주기 위함
  @Transient
  @JsonIgnore
  private boolean saved = false;

  @PostLoad
  private void postLoad() {
    this.saved = true;
  }

  @DateTimeFormat(pattern = DateUtil.PATTERN_YMD)
  private LocalDate expireDate;   //계정 만료일(null은 만료없음)

  @JsonIgnore
  @Column(columnDefinition = "bit(1) default 1", nullable = false)
  private boolean enabled = true;

  @JsonIgnore
  @Column(columnDefinition = "bit(1) default 0", nullable = false)
  private boolean locked = false;

  @JsonIgnore
  @Column(columnDefinition = "bit(1) default 0", nullable = false, insertable = false, updatable = false)
  private boolean superUser = false;

  @JsonIgnore
  @Column(columnDefinition = "bit(1) default 0", nullable = false, insertable = false)
  private boolean removed = false;

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    List<GrantedAuthority> authorities = new ArrayList<>(0);
    authorities.add(new SimpleGrantedAuthority(role.name()));
    return authorities;
  }

  /**
   * 신규 사용자이거나 비밀번호 란에 비밀번호를 입력한 경우 비밀번호 유효성 검사 필요
   */
  public boolean hasPasswordChanged() {
    try {
      return !password.isEmpty() || id == null;
    } catch (Exception e) {

    }
    return false;
  }

  @JsonIgnore
  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @JsonIgnore
  @Override
  public boolean isAccountNonExpired() {
//    if (expireDate != null) {
//      return expireDate.isAfter(LocalDate.now());
//    }
    return true;
  }

  @JsonIgnore
  @Override
  public boolean isAccountNonLocked() {
    return !locked && !removed;
  }

  @JsonIgnore
  @Override
  public boolean isEnabled() {
    return enabled && !removed;
  }

//  @JsonIgnore
//  public boolean hasRole(Role role) {
//    return this.role.equals(role);
//  }
}

 

  • domain-Certification
@Getter
@Setter
@Entity
@Table(name = "certification", indexes = {@Index(columnList = "phone")})
@NoArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Certification {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @EqualsAndHashCode.Include
  private Integer id;

  @Size(min = 12, max = 13)
  @NotNull
  private String phone;

  @Size(min = 6, max = 6)
  @NotNull
  private String authNumber;

  @JsonIgnore
  @Column(columnDefinition = "bit(1) default 0", nullable = false)
  private boolean certified = false;

  @JsonFormat(pattern = DateUtil.PATTERN_YMDHM)
  @DateTimeFormat(pattern = DateUtil.PATTERN_YMD)
  private LocalDateTime expireDate = LocalDateTime.now().plusMinutes(3);   //계정 만료일(null은 만료없음)
}

 

  • enumerate
// RoleBase
@Getter
@AllArgsConstructor
public enum RoleBase {
  ADMINISTRATOR("관리자"),
  USER("사용자");

  private String text;
}

//Role
@Getter
@AllArgsConstructor
public enum Role {
  ROLE_ADMIN("관리자", RoleBase.ADMINISTRATOR),
  ROLE_USER("사용자", RoleBase.USER);

  private String text;
  private RoleBase roleBase;

  public static List<Role> findByRoleBase(RoleBase roleBase) {
    return Arrays.stream(Role.values()).filter(p -> p.getRoleBase().equals(roleBase))
        .collect(Collectors.toList());
  }

}

❗ role을 사용하는 이유는 admin을 위해서!! 권한을 부여해줄 예정 ❗

 

  • config-SecurityConfig
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

  private static final int TOKEN_VALIDITY_TIME = 604800;

  @NonNull
  private final BCryptPasswordEncoder bCryptPasswordEncoder;
  @NonNull
  private final UserService userService;

  @Bean
  public FilterRegistrationBean<Filter> getSpringSecurityFilterChainBindedToError(
      @Qualifier("springSecurityFilterChain") Filter springSecurityFilterChain) {
    FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<>();
    registration.setFilter(springSecurityFilterChain);
    registration.setDispatcherTypes(EnumSet.allOf(DispatcherType.class));
    return registration;
  }

  @Bean
  public AuthenticationFailureHandler customAuthenticationFailureHandler() {
    return new CustomAuthenticationHandler();
  }

  @Bean
  public AuthenticationSuccessHandler customAuthenticationSuccessHandler() {
    return new CustomAuthenticationHandler();
  }

  @Bean
  public LoginUrlAuthenticationEntryPoint ajaxAwareAuthenticationEntryPoint() {
    return new AjaxAwareAuthenticationEntryPoint(SpringSecurity.LOGIN_URL);
  }

  @Bean
  public AccessDeniedHandler customAccessDeniedHandler() {
    return new CustomAccessDeniedHandler();
  }

  /**
   * 유저 DB의 DataSource와 Query 및 Password Encoder 설정.
   */
  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userService).passwordEncoder(bCryptPasswordEncoder);
  }

  /**
   * Spring Security에서 인증받지 않아도 되는 리소스 URL 패턴을 지정해 줍니다.
   */
  @Override
  public void configure(WebSecurity web) {
    web.ignoring().antMatchers("/static/**");
  }

  @Bean
  public CorsConfigurationSource corsConfigurationSource() {
    final CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(Collections.singletonList("*"));
    configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE", "PATCH"));
    // setAllowCredentials(true) is important, otherwise:
    // The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
    configuration.setAllowCredentials(true);
    // setAllowedHeaders is important! Without it, OPTIONS preflight request
    // will fail with 403 Invalid CORS request
    configuration.setAllowedHeaders(Collections.singletonList("Authorization"));
    final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
  }

  /**
   * Spring Security에 의해 인증받아야 할 URL 또는 패턴을 지정해 줍니다.
   */
  @Override
  protected void configure(HttpSecurity http) throws Exception {

    http.headers().frameOptions().sameOrigin()
        .and().csrf().disable().authorizeRequests()
        .antMatchers("/admin/**","/mypage/**","/comment/**").authenticated()
        .anyRequest().permitAll()
        .and().formLogin().loginPage(SpringSecurity.LOGIN_URL)
        .loginProcessingUrl(SpringSecurity.LOGIN_PROCESS_URL)
        .successHandler(customAuthenticationSuccessHandler())
        .failureHandler(customAuthenticationFailureHandler())
        .usernameParameter(SpringSecurity.PARAM_USERNAME)
        .passwordParameter(SpringSecurity.PARAM_PASSWORD)
        .and().logout()
        .logoutRequestMatcher(new AntPathRequestMatcher(SpringSecurity.LOGOUT_URL))
        .logoutSuccessUrl(SpringSecurity.LOGIN_URL)
        .and().exceptionHandling().authenticationEntryPoint(ajaxAwareAuthenticationEntryPoint())
        .accessDeniedHandler(customAccessDeniedHandler())
        .and().rememberMe().disable();
  }
}

 

  • config-MvcConfig
  @Bean(name = "bCryptPasswordEncoder")
  public BCryptPasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

❗ MvcConfig에 @Bean으로 추가를 해야 사용이 가능하다!! ❗

 

  • config-PasswordConfig
@Getter
@Setter
@Configuration
@ConfigurationProperties("password")
@PropertySources({@PropertySource(value = "classpath:/config/password.properties")})
public class PasswordConfig {

  private Validation validation;

  @Getter
  @Setter
  @Configuration
  @ConfigurationProperties("herbnet.password-validation")
  public static class Validation {

    private int length;
    private boolean specialCharacters;
    private boolean upperCases;
    private boolean numbers;
    private int maxAttemptsCount;
    private int changeCycle;
  }

}

❗ password.properties는 취향 것.. ❗

알아서 설정

  • config-기타
//AjaxAwareAuthenticationEntryPoint
public class AjaxAwareAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint {

  public AjaxAwareAuthenticationEntryPoint(String loginUrl) {
    super(loginUrl);
  }

  @Override
  public void commence(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException authException) throws IOException, ServletException {
    String ajaxHeader = request.getHeader("X-Requested-With");
    boolean isAjax = "XMLHttpRequest".equals(ajaxHeader);

    if (isAjax) {
      response
          .sendError(HttpServletResponse.SC_UNAUTHORIZED, "Ajax Request Denied (Session Expired)");
    } else {
      super.commence(request, response, authException);
    }
  }
}

//CustomAuthenticationHandler
public class CustomAuthenticationHandler
    implements AuthenticationSuccessHandler, AuthenticationFailureHandler {

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication auth) throws IOException {

    response.sendRedirect(request.getContextPath() + SpringSecurity.LOGIN_SUCCESS_URL);
  }

  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException exception) throws IOException, ServletException {

    String username = request.getParameter("username");
    String dispatcherURL = SpringSecurity.LOGIN_URL;

    request.setAttribute("SPRING_SECURITY_LAST_EXCEPTION", exception);
    request.setAttribute("SPRING_SECURITY_LAST_USERNAME", username);
    request.setAttribute("username", username);
    request.setAttribute("error", getErrorMessage(exception));
    request.getRequestDispatcher(dispatcherURL).forward(request, response);
  }

  /**
   * Spring Security 가 반환하는 에러 메시지 얻어옴
   *
   * @return 에러메시지
   */
  private String getErrorMessage(AuthenticationException exception) {

    String error;
    if (exception instanceof AccountExpiredException) {
      error = "사용 기간이 만료된 계정입니다.";
    } else if (exception instanceof BadCredentialsException) {
      error = "아이디 또는 비밀번호를 확인해주시기 바랍니다.";
    } else if (exception instanceof LockedException) {
      error = "로그인 반복 실패 또는 관리자에 의해 잠겨있는 계정입니다.\n관리자에게 문의하세요.";
    } else if (exception instanceof InternalAuthenticationServiceException) {
      error = "존재하지 않는 계정입니다.";
    } else if (exception instanceof DisabledException) {
      error = "승인되지 않은 계정입니다. 관리자의 승인을 기다려주세요.";
    } else if (exception instanceof CredentialsExpiredException) {
      error = "만료된 비밀번호 입니다.";
    } else {
      error = exception.getLocalizedMessage();
    }

    return error;
  }
}

//CustomAccessDeniedHandler
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

  @Override

  public void handle(HttpServletRequest request, HttpServletResponse response,
      AccessDeniedException accessDeniedException) throws IOException {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    if (authentication instanceof AnonymousAuthenticationToken) {
      response.sendRedirect(request.getContextPath() + SpringSecurity.LOGIN_URL);
    }
  }
}

 

  • util-ApplicationContextProvider
public class ApplicationContextProvider implements ApplicationContextAware {

  private static ApplicationContext applicationContext;

  public static <T> T getEnvironmentProperty(String key, Class<T> targetClass, T defaultValue) {
    if (key == null || targetClass == null) {
      throw new NullPointerException();
    }

    T value = null;
    if (applicationContext != null) {
      value = applicationContext.getEnvironment().getProperty(key, targetClass, defaultValue);
    }
    return value;
  }

  public void setApplicationContext(@NonNull ApplicationContext applicationContext) {
    ApplicationContextProvider.applicationContext = applicationContext;
  }
}

 

  • util-LoginAuth
public class LoginAuth {

  /* 계정 정보 */
  public static final String PASSWORD_SPECIAL_REGEXP = "(?=.*[!@#$%^&*()\\-_=+\\\\\\|\\[\\]{};:\\'\",.<>\\/?])";
  public static final String PASSWORD_UPPER_REGEXP = "(?=.*[A-Z])";
  public static final String PASSWORD_NUMBER_REGEXP = "(?=.*\\d)";
}

 

  • util-PasswordUtil
@UtilityClass
public class PasswordUtil {

  public static boolean isValidPassword(String password) {
    int length = ApplicationContextProvider
        .getEnvironmentProperty("validation.length", Integer.class, 0);
    boolean specialCharacters = ApplicationContextProvider
        .getEnvironmentProperty("validation.special-characters", Boolean.class,
            false);
    boolean upperCases = ApplicationContextProvider
        .getEnvironmentProperty("validation.upper-cases", Boolean.class, false);
    boolean numbers = ApplicationContextProvider
        .getEnvironmentProperty("validation.numbers", Boolean.class, false);

    String baseRegExp = "";
    String specialRegExp = "";
    String upperRegExp = "";
    String numberRegExp = "";

    if (specialCharacters) {
      specialRegExp = LoginAuth.PASSWORD_SPECIAL_REGEXP;
    }
    if (upperCases) {
      upperRegExp = LoginAuth.PASSWORD_UPPER_REGEXP;
    }
    if (numbers) {
      numberRegExp = LoginAuth.PASSWORD_NUMBER_REGEXP;
    }

    baseRegExp =
        "^" + specialRegExp + upperRegExp + numberRegExp
            + "[A-Za-z\\d!@#$%^&*()\\-_=+\\\\\\|\\[\\]{};:\\'\",.<>\\/?]{" + length + ",}";

    if (!StringUtils.isBlank(password) && password.matches(baseRegExp)) {
      return true;
    }
    return false;
  }

  public static String getPasswordRuleMessage() {
    String message = "비밀번호는 ";
    List<String> ruleIncluded = new ArrayList<>();

    int length = ApplicationContextProvider
        .getEnvironmentProperty("validation.length", Integer.class, 0);
    boolean specialCharacters = ApplicationContextProvider
        .getEnvironmentProperty("validation.special-characters", Boolean.class,
            false);
    boolean upperCases = ApplicationContextProvider
        .getEnvironmentProperty("validation.upper-cases", Boolean.class, false);
    boolean numbers = ApplicationContextProvider
        .getEnvironmentProperty("validation.numbers", Boolean.class, false);

    ruleIncluded.add("영문 소문자");

    if (specialCharacters) {
      ruleIncluded.add("특수문자");
    }

    if (upperCases) {
      ruleIncluded.add("영문 대문자");
    }

    if (numbers) {
      ruleIncluded.add("숫자");
    }

    if (!ruleIncluded.isEmpty()) {
      message += String.format("%s를 포함하여 ", StringUtils.join(ruleIncluded, ", "));
    }

    message += String.format("%d자리 이상으로 구성되어야 합니다.", length);

    return message;
  }
}

 

  • util-SpringSecurity
    public class SpringSecurity {
      public static final String LOGIN_URL = "/auth/login";
      public static final String LOGIN_PROCESS_URL = "/auth/login/process";
      public static final String LOGIN_SUCCESS_URL = "/";
      public static final String LOGOUT_URL = "/auth/logout";
      public static final String PARAM_USERNAME = "username";
      public static final String PARAM_PASSWORD = "password";
      public static final String PARAM_REMEMBER = "remember-me";
      public static final String KEY_REMEMBER = "알아서";
    }​

 

  • exception-UserPasswordValidationException
@SuppressWarnings("serial")
public class UserPasswordValidationException extends Exception {

  @Override
  public String getMessage() {
    return "비밀번호 규칙이 일치하지 않습니다";
  }
}

 

  • exception-DataNotFoundException
@ResponseStatus(code = HttpStatus.NOT_FOUND)
public class DataNotFoundException extends HttpClientErrorException {

  public DataNotFoundException() {
    super(HttpStatus.NOT_FOUND, "데이터를 찾을 수 없습니다.");
  }

  public DataNotFoundException(String message) {
    super(HttpStatus.NOT_FOUND, message);
  }
}

 

 

화면에서 파일을 클릭하면 다운로드를 받고 싶을 때

view 화면

      <div id="downloadList" class="mt-1 text-center">
          <span class="attachment" th:each="a : *{attachments}">
            <input type="hidden" name="attachments" th:value="${a.id}" th:field="*{attachments}"/>
            <a class="text-black" th:href="|@{/attachment/}${a.id}|">
              <div class="file-name" th:text="${a.fileName}"></div>
            </a>
          </span>
      </div>

 

연결이 /attachment/{id}로 되어 있으니 controller를 생성

AttachmentController

@RestController
@RequestMapping("/attachment")
@RequiredArgsConstructor
public class AttachmentController {

  @NonNull
  private final AttachmentService attachmentService;

  @GetMapping(value = "/{id}", produces = "application/octet-stream")
  public ResponseEntity attachment(@PathVariable("id") Long id) throws IOException {
    Attachment attachment = attachmentService.findById(id).orElseThrow(DataNotFoundException::new);
    File file = new File(attachment.getFilePath());

    if (file.exists() && file.length() > 0) {
      byte[] bytes = FileUtils.readFileToByteArray(file);

      HttpHeaders httpHeaders = new HttpHeaders();
      httpHeaders.setContentLength(bytes.length);
      httpHeaders.set("Content-Disposition",
          "attachment; filename=\"" + attachment.getEncodedFileName() + "\";");
      httpHeaders.set("Content-Transfer-Encoding", "binary");
      httpHeaders.setContentType(MediaType.valueOf(Files.probeContentType(file.toPath())));
      return new ResponseEntity(bytes, httpHeaders, HttpStatus.OK);
    }

    return ResponseEntity.notFound().build();
  }
}

 

해당 controller 함수에 맞게 repository랑 service 구현

AttachmentRepository

@Repository
public interface AttachmentRepository extends JpaRepository<Attachment, Long> {

}

 

AttachmentService

@Service
@RequiredArgsConstructor
public class AttachmentService {

  @NonNull
  private final AttachmentRepository attachmentRepository;

  @Cacheable(value = "attachment", key = "#id")
  public Optional<Attachment> findById(Long id) {
    return attachmentRepository.findById(id);
  }

  @CacheEvict(value = "attachment", key = "#attachment.id")
  public Attachment save(Attachment attachment) {
    return attachmentRepository.save(attachment);
  }
}

 

위의 과정을 거치면 파일 클릭시 다운로드 가능!!

domain 추가

(첨부파일: Attachment)

@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "attachment")
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Attachment {

  public Attachment(Long id) {
    this.id = id;
  }

  public Attachment(String fileName, String filePath, long fileSize, String fileContentType) {
    this.fileName = fileName;
    this.filePath = filePath;
    this.fileSize = fileSize;
    this.fileContentType = fileContentType;
  }

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @EqualsAndHashCode.Include
  private Long id;

  @Column(updatable = false, nullable = false)
  private String fileName; // 원본 파일명

  @Column(updatable = false, nullable = false, unique = true)
  private String filePath; // 실제 저장된 파일명

  @Column(updatable = false)
  private String fileContentType;

  @Column(updatable = false)
  private long fileSize;

  @PreRemove
  public void onRemove() {
    File file = new File(filePath);
    FileUtils.deleteQuietly(file);
  }

  @JsonIgnore
  public String getEncodedFileName() throws UnsupportedEncodingException {
    return URLEncoder.encode(this.fileName, "UTF-8").replace("+", "%20");
  }

  public String   getFileSizeText() {
    float size = fileSize;
    String unit = "Byte";
    if (size >= 1024) {
      unit = "KB";
      size /= 1024;
      if (size >= 1024) {
        unit = "MB";
        size /= 1024;
      }
    }
    return String.format("%d%s", (int) size, unit);
  }

  public String getIconPath() {
    String type = "file";
    if(fileContentType.toLowerCase().startsWith("image")) {
      type = "img";
    } else {
      String lowerCaseName = fileName.toLowerCase();
      if(lowerCaseName.endsWith("doc") || lowerCaseName.endsWith("docx")) {
        type = "doc";
      } else if(lowerCaseName.endsWith("hwp")) {
        type = "hwp";
      } else if(lowerCaseName.endsWith("pdf")) {
        type = "pdf";
      } else if(lowerCaseName.endsWith("xls") || lowerCaseName.endsWith("xlsx")) {
        type = "xl";
      } else if(lowerCaseName.endsWith("zip")) {
        type = "zip";
      } else if(lowerCaseName.endsWith("ppt") || lowerCaseName.endsWith("pptx")) {
        type = "ppt";
      }
    }
    return "static/img/files/" + String.format("icon-%s.png", type);
  }
}

 

service추가

(파일 업로드 관련)

@Service
@RequiredArgsConstructor
public class FileUploadService {

  public static final String PATH_LOGOS = "logos/";

  public static final String PATH_ATTACHMENTS = "attachments/";

  @Value("${spring.servlet.multipart.location}")
  private String rootPath;

  public Attachment upload(MultipartFile file, String destinationPath) throws IOException {
    if (file == null || file.isEmpty()) {
      return null;
    }

    String fileName = file.getOriginalFilename();
    String fileExtension = FilenameUtils.getExtension(fileName);
    if (StringUtils.isBlank(fileExtension)) {
      return null;
    }

    File destinationFile;
    String destinationFileName;
    do {
      destinationFileName = destinationPath + FileUtil.getRandomFilename(fileExtension);
      destinationFile = new File(rootPath + destinationFileName);
    } while (destinationFile.exists());

    //noinspection ResultOfMethodCallIgnored
    destinationFile.getParentFile().mkdirs();
    file.transferTo(destinationFile);

    Attachment attachment = new Attachment();

    //이미지인 경우 리사이징
    if (file.getContentType() != null && file.getContentType().contains("image")) {
      BufferedImage image = ImageIO.read(destinationFile);

      double maxThumbnailWidth = 1280.0;
      double thumbnailRatio =
          maxThumbnailWidth > image.getWidth() ? 1 : maxThumbnailWidth / image.getWidth();

      //이미지 사이즈 변경
      BufferedImage resizedImage = resize(image, thumbnailRatio, Image.SCALE_FAST);
      ImageIO.write(resizedImage, "JPEG", destinationFile);
    }

    attachment.setFileName(file.getOriginalFilename());
    attachment.setFilePath(FilenameUtils.normalize(destinationFile.getAbsolutePath(), true));
    attachment.setFileContentType(file.getContentType());
    attachment.setFileSize(file.getSize());
    return attachment;
  }

  public Attachment uploadFile(MultipartFile file) throws IOException {
    return upload(file, PATH_ATTACHMENTS);
  }

  public Attachment uploadLogo(MultipartFile file) throws IOException {
    return upload(file, PATH_LOGOS);
  }

  private BufferedImage resize(BufferedImage beforeImage, double ratio, int imageScale) {
    int newW = (int) (beforeImage.getWidth() * ratio);
    int newH = (int) (beforeImage.getHeight() * ratio);
    Image tmp = beforeImage.getScaledInstance(newW, newH, imageScale);
    BufferedImage dimg = new BufferedImage(newW, newH, beforeImage.getType());

    Graphics2D g2d = dimg.createGraphics();
    g2d.drawImage(tmp, 0, 0, null);
    g2d.dispose();

    return dimg;
  }
}

 

파일을 첨부할 controller 수정

 

  @PostMapping("/save")
  public String save(@ModelAttribute("project") Project project, BindingResult result,
      MultipartFile[] files) {

    if (!result.hasErrors()) {
      if (files != null) {
        for (MultipartFile file : files) {
          try {
            project.getAttachments().add(fileUploadService.uploadFile(file));
          } catch (IOException e) {
            e.printStackTrace();
          }
        }
      }
      projectService.save(project);
      return "project/edit";
    }
    return null;
  }

 

화면에 폼 추가하기

(project/edit)

<form method="post" th:action="|@{/project/save}|" th:object="${project}" enctype="multipart/form-data">
 <!--        첨부파일-->
 <div class="d-flex mt-3 mb-2 justify-content-between">
 	<label class="form-label m-0 mr-2">Attachment</label>
 	<div>
 		<label for="input-file" id="addFile" class="input-file-button">Upload</label>
 		<input type="file" id="input-file" name="files" style="display: none;" multiple/>
 	</div>
 </div>
 </form>

enctype="multipart/form-data" 이게 중요하다! 이걸 표시해줘야 파일이란 것을 알 수 있음!!

 

본인의 프로젝트에 맞게 설정을 잘하고 저장버튼을 누르면 파일이 업로드 됨을 볼 수 있다.

DB에 추가됨

'Web > Spring' 카테고리의 다른 글

[Login구현] login-logout구현(setting)  (0) 2021.08.10
[MultipartFile] 첨부파일 다운로드  (0) 2021.08.06
[properties] server.port vs server.http.port  (0) 2021.08.06
[ERROR] 유의해야 할 점  (0) 2021.08.06
[Annotation] @PathVariable  (0) 2021.08.06

운영에 있어서 http와 https 포트 두 개를 모두 열기 위함!

"멀티 포트"

 

 

/config/ServletConfig 파일 설정

@Configuration
public class ServletConfig {

  @Value("${server.http.port}")
  private int httpPort;
  @Value("${server.port}")
  private int httpsPort;
  
  @Bean
  public ServletWebServerFactory servletWebServerFactory() {
    TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory() {
      @Override
      protected void postProcessContext(Context context) {
        SecurityConstraint securityConstraint = new SecurityConstraint();
        securityConstraint.setUserConstraint("CONFIDENTIAL");
        SecurityCollection collection = new SecurityCollection();
        collection.addPattern("/*");
        securityConstraint.addCollection(collection);
        context.addConstraint(securityConstraint);
      }
    };

    tomcat.addAdditionalTomcatConnectors(createStandardConnector());
    return tomcat;
  }

  private Connector createStandardConnector() {
    Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
    connector.setPort(httpPort);
    connector.setScheme("http");
    connector.setSecure(false);
    connector.setRedirectPort(httpsPort);
    return connector;
  }
}

 

properties 설정

server.port=443
server.http.port=80

#SSL 설정
server.ssl.enabled=true
server.ssl.key-store=
server.ssl.key-store-type=
server.ssl.ciphers=
server.ssl.key-store-password=
server.ssl.key-alias= 도메인

'Web > Spring' 카테고리의 다른 글

[MultipartFile] 첨부파일 다운로드  (0) 2021.08.06
[MultipartFile] 첨부파일 업로드  (0) 2021.08.06
[ERROR] 유의해야 할 점  (0) 2021.08.06
[Annotation] @PathVariable  (0) 2021.08.06
[Pageable] 페이징 처리 - 기본  (0) 2021.08.06

+ Recent posts