[Spring Security] 스프링 시큐리티

황인찬·2024년 8월 5일
post-thumbnail

프로젝트 생성

의존성

  • 필수 의존성
    • Spring Web
    • Lombok
    • Mustache
    • Spring Security
    • Spring Data JPA
    • MySQL Driver

메인 페이지

  • 메인 컨트롤러
@Controller
public class MainController {

    @GetMapping("/")
    public String mainP() {
        return "main";
    }
}
  • main.mustache
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Main Page</title>
</head>
<body>
main page
</body>
</html>

Security Config 클래스

인가

  • 특정한 경로에 요청이 오면 controller에 도달하기 전 필터에서 Spring Security가 검증
    • 해당 경로의 접근은 누구에게 열려있는지
    • 로그인이 완료된 사용자인지
    • 해당되는 role을 가지고 있는지

Security Configuration

  • SecurityFilterChain 설정을 진행

Security Config 클래스 작성

  • hasRole(Role) : 해당 Role 을 갖고있는 사용자 허용
  • hasAnyRole(Role1, Role2, ...) : 해당 Role 중에 1개이상 갖고있는 사용자 허용
  • anonymous() : 익명 사용자 허용
  • authenticated() : 인증된 사용자 허용
  • permitAll() : 모든 사용자 허용
  • denyAll() : 모든 사용자 거부
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/", "/login").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .requestMatchers("/my/**").hasAnyRole("ADMIN", "USER")
                        .anyRequest().authenticated()
                );

        return http.build();
    }
}

커스텀 로그인 설정

Config 설정 후 로그인 페이지

  • 특정 경로에 대한 접근 권한이 없는 경우 자동으로 로그인 페이지로 리다이렉트 되지 않고 오류 발생
  • 로그인 페이지 설정을 진행해야함

커스텀 로그인 페이지

  • login.mustache
    • 로그인: ID, PW, POST 요청
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Login Page</title>
</head>
<body>
login page
<hr>
<form action="/loginProc" method="post" name="loginForm">
    <input id="username" type="text" name="username" placeholder="id"/>
    <input id="password" type="password" name="password" placeholder="password"/>
    <input type="submit" value="login"/>
</form>

</body>
</html>
  • LoginController
@Controller
public class LoginController {

    @GetMapping("/login")
    public String loginP() {
        return "login";
    }
}

Security Config 로그인 페이지 설정 및 로그인 경로

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/", "/login").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .requestMatchers("/my/**").hasAnyRole("ADMIN", "USER")
                        .anyRequest().authenticated()
                );

        http
                .formLogin((auth) -> auth
                        .loginPage("/login")
                        .loginProcessingUrl("/loginProc")
                        .permitAll()
                );

        http
                .csrf((auth) -> auth
                        .disable()
                );

        return http.build();
    }
}

Bcrypt 암호화 메소드

시큐리티 암호화

  • 스프링 시큐리티는 로그인시 비밀번호에 대해 단방향 해시 암호화를 진행하여 저장되어 있는 비밀번호와 대조
  • 암호화 진행시 BCryptPasswordEncoder를 제공하고 권장

해시 암호화

  • 양방향
    • 대칭키
    • 비대칭키
  • 단방향(복호화 불가능)
    • 해시

SecurityConfig에 암호화 추가

@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
	return new BCryptPasswordEncoder();
}

회원 가입 로직

회원 가입 페이지

  • join.mustache
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Join Page</title>
</head>
<body>
join page
<hr>
<form action="/joinProc" method="post" name="joinForm">
    <input id="username" type="text" name="username" placeholder="Username"/>
    <input id="password" type="password" name="password" placeholder="Password"/>
    <input type="submit" value="Join"/>
</form>
</body>
</html>

JoinDto

@Getter
@Setter
public class JoinDto {

    private String username;
    private String password;
}

JoinController

@Controller
@RequiredArgsConstructor
public class JoinController {

    private final JoinService joinService;

    @GetMapping("/join")
    public String joinP() {
        return "join";
    }

    @PostMapping("/joinProc")
    public String joinProcess(JoinDto joinDto) {
        joinService.joinProcess(joinDto);
        return "redirect:/login";
    }
}

JoinService

@Service
@RequiredArgsConstructor
public class JoinService {

    private final UserRepository userRepository;

    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public void joinProcess(JoinDto joinDto) {

        //db에 동일한 username을 가진 회원이 존재하는지 검증

        User user = new User();
        user.setUsername(joinDto.getUsername());
        user.setPassword(bCryptPasswordEncoder.encode(joinDto.getPassword()));
        user.setRole("ROLE_USER");
        userRepository.save(user);
    }
}

User 엔티티

@Entity
@Getter
@Setter
public class User {

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

    private String username;

    private String password;

    private String role;
}

UserRepository

public interface UserRepository extends JpaRepository<User, Long> {
}

SecurityConfig 접근 권한

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/", "/login", "/loginProc", "/join", "/joinProc").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .requestMatchers("/my/**").hasAnyRole("ADMIN", "USER")
                        .anyRequest().authenticated()
                );

        http
                .formLogin((auth) -> auth
                        .loginPage("/login")
                        .loginProcessingUrl("/loginProc")
                        .permitAll()
                );

        http
                .csrf((auth) -> auth
                        .disable()
                );

        return http.build();
    }
}

회원 중복 검증 및 처리

User 엔티티 unique 설정

@Entity
@Getter
@Setter
public class User {

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

    @Column(unique = true)
    private String username;

    private String password;

    private String role;
}

JoinService 회원 중복 검증

  • UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
    boolean existsByUsername(String username);
}
  • JoinService
@Service
@RequiredArgsConstructor
public class JoinService {

    private final UserRepository userRepository;

    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public void joinProcess(JoinDto joinDto) {

        //db에 동일한 username을 가진 회원이 존재하는지 검증
        boolean isUser = userRepository.existsByUsername(joinDto.getUsername());
        if (isUser) {
            return;
        }

        User user = new User();
        user.setUsername(joinDto.getUsername());
        user.setPassword(bCryptPasswordEncoder.encode(joinDto.getPassword()));
        user.setRole("ROLE_USER");
        userRepository.save(user);
    }
}
  • 가입 불가 문자 처리(정규식 처리)
    • 아이디의 자리수
    • 아이디의 특수문자 포함 불가
    • admin과 같은 아이디 사용 불가
    • 비밀번호 자리수
    • 비밀번호 특수문자 포함 필수

DB 기반 로그인 검증 로직

인증

  • 시큐리티를 통해 인증하는 방법
    • Login 페이지를 통해 ID, PW를 post 요청
    • 스프링 시큐리티가 DB 회원 정보를 조회 후 비밀번호 검증
    • 서버 세션 저장소에 해당 아이디 세션 저장

CustomUserDetailsService

  • UserDetailsService를 implements 하여 구현
  • CustomUserDetails의 생성자로 감싸서 리턴해야함
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username);

        if (user != null) {
            return new CustomUserDetails(user);
        }

        return null;
    }
}

UserRepository

  • username을 통해 user를 찾는 기능 추가
public interface UserRepository extends JpaRepository<User, Long> {
    boolean existsByUsername(String username);

    User findByUsername(String username);
}

CustomUserDetails

  • UserDetails를 implements 하여 구현
public class CustomUserDetails implements UserDetails {

    private User user;

    public CustomUserDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();

        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole();
            }
        });

        return collection;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

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

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

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

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

세션 정보

세션 현재 사용자 아이디

String id = SecurityContextHolder.getContext().getAuthentication().getName();

세션 현재 사용자 role

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iter = authorities.iterator();
GrantedAuthority auth = iter.next();
String role = auth.getAuthority();

세션 설정

로그인 정보

  • 사용자가 로그인을 진행한 뒤 SecurityContextHolder에 의해 서버에서 세션이 관리됨

세션 소멸 시간 설정

  • 세션 소멸 시점은 서버에 마지막 특정 요청을 수행한 뒤 설정 시간만큼 유지
    • 기본 시간은 1800초
  • application.yml
    • 초 단위
    server:
        session:
          timeout: 1800
    • 분 단위
    server:
        session:
          timeout: 90m

다중 로그인 설정

  • 동일한 아이디로 다중 로그인을 진행할 시 세션 통제를 통해 제한 가능
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{

    http
            .sessionManagement((auth) -> auth
                    .maximumSessions(1)
                    .maxSessionsPreventsLogin(true));

    return http.build();
}
  • sessionManagement() 메소드를 통해 설정 진행
  • maximumSessions(정수): 하나의 아이디에 대한 다중 로그인 허용 개수
  • maxSessionspreventsLogin(boolean): 다중 로그인 개수를 초과 하였을 때 처리 방법
    • true: 초과시 새로운 로그인 차단
    • false: 초과시 기존 세션 하나 삭제

세션 고정 보호

  • sessionManagement()메소드의 sessionFixation()메소드를 통해서 세션 고정 공격 보호 설정 가능
    • sessionManagement().sessionFixation().none(): 로그인 시 세션 정보 변경X
    • sessionManagement().sessionFixation().newSession(): 로그인 시 새로운 세션 생성
      sessionManagement().sessionFixation().changeSessionId: 로그인 시 동일한 세션에 대한 id 변경
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{

    http
            .sessionManagement((auth) -> auth
                    .sessionFixation().changeSessionId());

    return http.build();
}

CSRF 설정

CSRF란?

  • CSRF(Cross-Site Request Forgery)는 요청을 위조하여 사용자가 원치 않아도 서버 측으로 특정 요청을 강제로 보내는 방식(회원 정보 변경, CRUD를 사용자 모르게 요청 등)

개발 환경에서 csrf disable()

  • 개발 환경에서는 SecurityConfig에서 csrf를 disable 설정하였음
  • 배포 환경에서는 csrf 공격을 막기 위해 추가 설정을 진행해야함

배포 환경에서의 진행 상황

  • SecurityConfig
    • csrf.disable()구문 삭제
  • POST 요청 설정(mustache 기준)
<form action="/loginProc" method="post" name="loginForm">
    <input id="username" type="text" name="username" placeholder="id"/>
    <input id="password" type="password" name="password" placeholder="password"/>
    <input type="hidden" name="_csrf" value="{{_csrf.token}}"/>
    <input type="submit" value="login"/>
</form>
  • ajax 요청시
    • HTML <head> 구획에 아래 요소 추가
    • ajax 요청시 위의 content 값을 가져온 후 함께 요청
    • XMLHttpRequest 요청시 setRequestHeader를 통해 csrf, csrf_header Key에 대한 토큰 값을 넣어 요청
<meta name="_csrf" content="{{_csrf.token}}"/>
<meta name="_csrf_header" content="{{_csrf.headerName}}"/>
  • GET 방식 로그아웃을 처리할 때 설정

    • csrf 설정시 POST 요청으로 로그아웃을 진행해야 하지만 아래 방식을 통해 GET 방식으로 진행할 수 있음

    • SecurityConfig 로그아웃 설정

      @Bean
      public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
      
        http
                .logout((auth) -> auth.logoutUrl("/logout")
                        .logoutSuccessUrl("/"));
      
        return http.build();
      }
    • LogoutController

      @Controller
      public class LogoutController {
      
        @GetMapping("/logout")
        public String logout(HttpServletRequest request, HttpServletResponse response) {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication != null) {
                new SecurityContextLogoutHandler().logout(request, response, authentication);
            }
            return "redirect:/";
        }
      }

CSRF 오류 발생 시

  • mustache에서 csrf 토큰 변수 오류 발생시 아래 구문을 변수 설정 파일에 추가
  • application.yml 설정
spring:
  mustache:
    servlet:
      expose-request-attributes: true

API 서버의 경우 csrf.disable()

  • 앱에서 사용하는 API 서버의 경우 세션을 stateless로 관리하기 때문에 csrf enable 설정을 진행하지 않아도 됨

InMemory 방식 유저 저장

소수의 유저를 저장할 수 있는 방법

  • 토이 프로젝트를 진행하는 경우 시큐리티 로그인 환경이 필요하지만 소수의 회원만 가지며 데이터베이스 자원에 투자가 힘들 경우 회원가입이 필요 없는 InMemory 방식을 사용하면 해결 가능

InMemory 방식 저장 코드

@Configuration
@EnableWebSecurity
public class SecurityConfig {

		@Bean
    public UserDetailsService userDetailsService() {

        UserDetails user1 = User.builder()
                .username("user1")
                .password(bCryptPasswordEncoder().encode("1234"))
                .roles("ADMIN")
                .build();

        UserDetails user2 = User.builder()
                .username("user2")
                .password(bCryptPasswordEncoder().encode("1234"))
                .roles("USER")
                .build();

        return new InMemoryUserDetailsManager(user1, user2);
    }
}

HttpBasic 인증

로그인 방식

  • formLogin 방식
  • httpBasic 방식

httpBasic 방식

  • ID, PW를 Base64로 인코딩한 뒤 HTTP 인증 헤더에 부착하여 서버측으로 요청을 보내는 방식

SecurityConfig 클래스 설정

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

    http
            .httpBasic(Customizer.withDefaults());

    return http.build();
}

Role Hierarchy

계층 권한 : Role Hierarchy

  • 권한 A,B,C가 존재하고 권한 계층을 A<B<C로 설정하고 싶은 경우 Role Hierarchy를 통해 설정 가능
  • fromHierarchy()메소드 사용
@Bean
public RoleHierarchy roleHierarchy() {

    return RoleHierarchyImpl.fromHierarchy("""
            ROLE_C > ROLE_B
            ROLE_B > ROLE_A
            """);
}
  • 메소드 형식 : 명시적으로 접두사 작성
@Bean
public RoleHierarchy roleHierarchy() {

    return RoleHierarchyImpl.withRolePrefix("접두사_")
            .role("C").implies("B")
            .role("B").implies("A")
            .build();
}
  • 메소드 형식 : 자동으로 ROLE_ 접두사 붙임
@Bean
public RoleHierarchy roleHierarchy() {

    return RoleHierarchyImpl.withDefaultRolePrefix()
            .role("C").implies("B")
            .role("B").implies("A")
            .build();
}

참조 링크
개발자 유미 - 스프링 시큐리티

profile
찬이's 개발로그

0개의 댓글