Spinrg Security에서 session 로그인은 대부분 화면이 있고 form 로그인으로 구현되어 있기에 여러 강의와 자료를 참고하며 api 방식으로 구현해 보았고 이를 기록한다.
@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());
}
}
.antMatchers("~~").permitAll()
패턴("~~")에 해당되는 요청은 인증이 필요 없다.
.anyRequest().authenticated()
패턴에 해당되지 않는 요청들은 모두 인증이 필요하다.
.accessDeniedHandler(accessDeniedHandler())
.authenticationEntryPoint(authenticationEntryPointHandler())
// 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();
}
}
@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
@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;
}
}
Spring Security에서 권한명은 ROLE_ prefix를 붙여야 한다.
회원 가입을 할 때 권한(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;
}
}
지난 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;
}
}
설정 파일에 아래와 같은 내용을 추가하면 된다.
server:
servlet:
session:
cookie:
http-only: true
secure: true