Spring Security with Session

yshjft·2022년 2월 3일
1

Spring Security

목록 보기
2/6

Spinrg Security에서 session 로그인은 대부분 화면이 있고 form 로그인으로 구현되어 있기에 여러 강의와 자료를 참고하며 api 방식으로 구현해 보았고 이를 기록한다.

SecurityConfig.java

@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationEntryPoint authenticationEntryPointHandler() {
        return new CustomAuthenticationEntryPoint();
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/api/user/signup", "/api/auth/login", "/api/exception/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .exceptionHandling()
                .accessDeniedHandler(accessDeniedHandler())
                .authenticationEntryPoint(authenticationEntryPointHandler());
    }
}

.csrf().disable()

.authorizeRequests()

  • .antMatchers("~~").permitAll()
    패턴("~~")에 해당되는 요청은 인증이 필요 없다.

  • .anyRequest().authenticated()
    패턴에 해당되지 않는 요청들은 모두 인증이 필요하다.

.exceptionHandling()

  • .accessDeniedHandler(accessDeniedHandler())

    • 권한이 없는 경우
    • CustomAccessDeniedHandler를 이용하여 예외 처리 api로 redirect
    • ExceptionController에서 AccessDenied 발생시키고 이를 알맞은 ExceptionHandler가 처리
    • 403(FORBIDDEN)
  • .authenticationEntryPoint(authenticationEntryPointHandler())

    • 인증을 실패한 경우
    • CustomAuthenticationEntryPoint를 이용하여 예외 처리 api로 redirect
    • ExceptionController에서 UnauthorizedException 발생시키고 이를 알맞은 ExceptionHandler가 처리
    • 401(UNAUTHORIZED)
    // CustomAccessDeniedHandler.java
    public class CustomAccessDeniedHandler implements AccessDeniedHandler {
        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
            response.sendRedirect("/api/exception/accessDenied");
        }
    }
    
    // CustomAuthenticationEntryPoint.java
    public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
            response.sendRedirect("/api/exception/unauthorized");
        }
    }
    
    // ExceptionController.java
    @RestController
    @RequestMapping("/api/exception")
    public class ExceptionController {
        @GetMapping("/accessDenied")
        public void accessDeniedException() {
            throw new AccessDenied();
        }
        @GetMapping("/unauthorized")
        public void unAuthorizedException() {
            throw new UnauthorizedException();
        }
    }

login & logout

AuthService.java

@Service
@RequiredArgsConstructor
public class AuthService {
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    public String login(LoginReqDto loginReqDto) {
        try{
              // authenticationToken: 인증용 객체
              UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginReqDto.getEmail(), loginReqDto.getPassword());
              // .authenticate(): 접근 주체 인증(CustomUserDetailsService의 loadUserByUsername 실행)
              // 인증이 완료된 경우 authentication 객체를 반환
              Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
              // authentication 객체 세션에 저장
              SecurityContextHolder.getContext().setAuthentication(authentication);

              return "login success";
          }catch (Exception e) {
              // 인증 실패
              throw new LoginFailException();
          }
    }

    public String logout() {
        HttpSession session = SessionUtil.getSession();
        if(session != null) {
            session.invalidate();
        }

        return "logout success";
    }
}

아래의 코드를 통해서 Spring Security의 AuthenticationFilter, ProviderManager, AuthenticationProvider의 일을 모두 수행하는 것 같다.

UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginReqDto.getEmail(), loginReqDto.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

CustomUserDetailsService.java & CustomUserDetails.java

// CustomUserDetailsService.java
@Component("userDetailsService")
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String email = username;
        User user = userRepository.findUserWithAuthoritiesByEmail(email).orElseThrow(() -> new UsernameNotFoundException("no user"));

        List<GrantedAuthority> grantedAuthorities = user.getAuthorities().stream()
                .map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName().toString()))
                .collect(Collectors.toList());

        return CustomUserDetails.builder()
                .id(user.getId())
                .email(user.getEmail())
                .password(user.getPassword())
                .authorities(grantedAuthorities)
                .build();
    }
}

// CustomUserDetails.java 
@Getter
public class CustomUserDetails implements UserDetails {
    private Long id;
    private  String email;
    private String password;
    private Collection<GrantedAuthority> authorities;

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

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

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Builder
    public CustomUserDetails(Long id, String email, String password, Collection<GrantedAuthority> authorities) {
        this.id = id;
        this.email = email;
        this.password = password;
        this.authorities = authorities;
    }
}

ROLE_ prefix

Spring Security에서 권한명은 ROLE_ prefix를 붙여야 한다.

custom validation

회원 가입을 할 때 권한(ROLE_USER, ROLE_ADMIN)을 입력해야했고 이에 대한 custom validation이 필요하여 이를 구현하였다.

// Authorities.java
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = AuthoritiesValidator.class)
public @interface Authorities {
    String message() default "invalid authorities input";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// AuthoritiesValidator
@Component
public class AuthoritiesValidator implements ConstraintValidator<Authorities, String[]> {

    @Override
    public boolean isValid(String[] authorities, ConstraintValidatorContext context) {
        if(authorities == null || authorities.length == 0) {
            context.disableDefaultConstraintViolation();
            // detail reason 설정
            context.buildConstraintViolationWithTemplate(
                    MessageFormat.format("not null", null)
            ).addConstraintViolation();
            return false;
        }

        for(String authority : authorities) {
            if(authority.equals("ROLE_USER") || authority.equals("ROLE_ADMIN")) continue;

            context.disableDefaultConstraintViolation();
            // detail reason 설정
            context.buildConstraintViolationWithTemplate(
                    MessageFormat.format("only ROLE_USER and ROLE_ADMIN", null)
            ).addConstraintViolation();
            return false;
        }

        return true;
    }
}
// DTO
@Getter
@NoArgsConstructor
public class SignUpReqDto {
    @NotBlank
    @Email
    private String email;

    @NotBlank
    @Length(min=4, max=20)
    private String password;

    @NotBlank
    private String name;

    @Authorities
    private String[] authorities;

    @Builder
    public SignUpReqDto(String email, String password, String name, String[] authorities) {
        this.email = email;
        this.password = password;
        this.name = name;
        this.authorities = authorities;
    }
}

세션 저장소를 redis로 변경하기

지난 BankSystem에서 누락된 내용이기도 하고 이번에 시간을 낭비하기도 한 부분이여서 정리한다.

application.yml redis 관련 내용 추가

spring:
  redis:
    host: localhost
    port: 6379
    password:
  session:
    store-type: redis
    redis:
      flush-mode: on_save

RedisConfig.java 추가

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

@Configuration
@EnableRedisHttpSession
@RequiredArgsConstructor
public class RedisConfig {
    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private Integer port;

    @Value("${spring.redis.password}")
    private String password;

    private final ObjectMapper objectMapper;

    @Bean
    public RedisConnectionFactory lettuceConnectionFactory() {
        RedisStandaloneConfiguration standaloneConfiguration = new RedisStandaloneConfiguration(host, port);
        standaloneConfiguration.setPassword(password.isEmpty() ? RedisPassword.none() : RedisPassword.of(password));
        return new LettuceConnectionFactory(standaloneConfiguration);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

        redisTemplate.setConnectionFactory(lettuceConnectionFactory());
        redisTemplate.setEnableTransactionSupport(true);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));

        return redisTemplate;
    }
}
  • session 저장소를 redis로 설정한 경우 원하는 session timeout 설정을 위해서는 @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 원하는 시간(초 단위))으로 해야한다.
  • 따로 설정하지 않을 경우 1800s로 timeout이 설정된다.

Spring Security의 httpOnly, secure 설정

설정 파일에 아래와 같은 내용을 추가하면 된다.

server:
  servlet:
    session:
      cookie:
        http-only: true
        secure: true

코드

Spring Security Session 깃허브 레파지토리

profile
꾸준히 나아가자 🐢

0개의 댓글