seungriyou / spring-study

자바 스프링 부트를 배워봅시다 🔥
0 stars 0 forks source link

[강의 정리] 08. 스프링 시큐리티 #16

Open seungriyou opened 9 months ago

seungriyou commented 9 months ago

스프링 시큐리티

Contents


H2 URL

그냥 MySQL로 바꿨다... #17

jdbc:h2:tcp://localhost/~/workspace/spring-study/spring-study/08-security/TestSecurity/test
seungriyou commented 9 months ago

1. 기본

1. 실습 목표 및 간단한 동작 원리

서블릿 컨테이너 내에는 필터가 존재하여 요청이 필터를 거쳐서 들어오게 된다.

Spring Security Config는 필터를 만들어서, 어떤 요청이 어떤 컨트롤러에 권한이 있는지를 확인한다.

image


2. 프로젝트 생성

image

현재는 스프링 시큐리티가 전체 페이지에 적용되어 있다!

추후 시큐리티 config 클래스를 생성해서 어떤 페이지에 적용할지 지정해야 한다.


다음과 같이 스프링 시큐리티에서 비밀번호를 제공해주는데, 이때 id는 user 로 작성하면 로그인이 가능하다.

image


3. Security Config 클래스

image

인가

특정한 경로에 요청이 오면 Controller 클래스에 도달하기 전 필터에서 Spring Security가 검증을 한다.

  1. 해당 경로의 접근은 누구에게 열려 있는지
  2. 로그인이 완료된 사용자인지
  3. 해당되는 role을 가지고 있는지


Security Configuration

@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")  // wildcard 사용 가능
                        .anyRequest().authenticated()   // 로그인 한 사용자들은 접근 가능하도록
                );

        return http.build();    // 빌드해서 리턴
    }
}

admin 페이지도 생성해보았다. 아직 role 관련 기능을 구현하지 않았으므로 액세스 거부가 뜬다!


버전별 Security Config 구현 방법

스프링 시큐리티의 경우, 버전별로 구현 방법이 많이 다르므로 버전마다 구현 특징을 확인해야 한다.

https://github.com/spring-projects/spring-security/releases

스프링 부트 2.7.X 버전부터 SecurityFilterChain 인터페이스를 리턴하는 스프링 빈 등록해야 하는 형태로

스프링 부트 3.1.X 버전부터 람다 형식 표현 필수!

public class SpringSecurityConfig {

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

        http
            .authorizeHttpRequests((auth) -> auth
                  .requestMatchers("/login", "/join").permitAll()
                  .anyRequest().authenticated()
        );

        return http.build();
    }
}


4. 커스텀 로그인 설정

커스텀 로그인 페이지

아이디, 비밀번호 → POST 요청 경로


Security Config에 추가

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        ...

        // === 로그인 === //
        http
                .formLogin((auth) -> auth.loginPage("/login")   // 로그인 페이지 경로 (admin 페이지 접근 시 자동으로 리다이렉트)
                        .loginProcessingUrl("/loginProc")       // POST 방식으로 로그인 데이터를 넘긴다.
                        .permitAll()                            // 누구나 들어올 수 있다.
                );

        // 개발 환경에서는 잠시 disable 시켜두자.
        http
                .csrf((auth) -> auth.disable());                // 스프링 시큐리티에서는 기본적으로 csrf 설정이 되어있는데, 이러면 POST 시 csrf 토큰도 보내주어야 한다.

        return http.build();    // 빌드해서 리턴
    }
}
seungriyou commented 9 months ago

2. 회원 가입

5. BCrypt 암호화 메서드

스프링 시큐리티는 사용자 인증(로그인)시 비밀번호에 대해 단방향 해시 암호화를 진행하여 저장되어 있는 비밀번호와 대조한다.

따라서 회원가입시 비밀번호 항목에 대해서 암호화를 진행해야 한다.

단방향 해시 암호화

  • 양방향
    • 대칭키
    • 비대칭키
  • 단방향
    • 해시

스프링 시큐리티는 암호화를 위해 BCrypt Password Encoder를 제공하고 권장한다. 따라서 해당 클래스를 return하는 메소드를 만들어 @Bean으로 등록하여 사용하면 된다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {

        return new BCryptPasswordEncoder();
    }

    ...


6. DB 연결

회원 정보를 저장하기 위한 데이터베이스는 MySQL 엔진의 데이터베이스를 사용한다. 그리고 접근은 Spring Data JPA를 사용한다.

17

image


application.yml에 다음을 작성한다.

spring:
  datasource:
    # jdbc:mysql://아이피:3306/데이터베이스?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
    url: jdbc:mysql://localhost:3306/practice?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
    username: sryou
    password: 1234
    driver-class-name: com.mysql.cj.jdbc.Driver


7. 회원가입 로직

회원가입 모식도

image


어플리케이션 실행 시 DB 테이블 생성하기 (DDL)

applications.yml

jpa:
    hibernate:
      ddl-auto: create
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

image


JoinDto

@Getter
@Setter
public class JoinDTO {

    private String username;
    private String password;
}


JoinController

@Controller
public class JoinController {

    // TODO: 추후 생성자 주입으로 변경 필요
    @Autowired
    private JoinService joinService;

    @GetMapping("/join")
    public String joinP() {

        return "join";
    }

    @PostMapping("/joinProc")
    public String joinProcess(JoinDTO joinDTO) {

        System.out.println("joinDTO.getUsername() = " + joinDTO.getUsername());
        joinService.joinProcess(joinDTO);

        return "redirect:/login";
    }
}


JoinService

@Service
public class JoinService {

    // TODO: 추후 생성자 주입으로 변경 필요
    @Autowired
    private UserRepository userRepository;

    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    public void joinProcess(JoinDTO joinDTO) {
        // TODO: 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)

@Entity
@Getter
@Setter
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    @Enumerated(EnumType.STRING)
    private Role role;
}


UserRepository

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


Secruity Config

추가!

http
        // 람다식으로 작성
        // 작성 순서대로 우선순위 부여되므로 주의
        .authorizeHttpRequests((auth) -> auth
                .requestMatchers("/", "/login", "/loginProc", "/join", "/joinProc").permitAll() // 모두 접근 가능
                .requestMatchers("/admin").hasRole("ADMIN")
                .requestMatchers("/my/**").hasAnyRole("ADMIN", "USER")  // wildcard 사용 가능
                .anyRequest().authenticated()   // 로그인한 사용자들은 접근 가능하도록
        );


회원 중복 검증 방법***

username에 대해서 중복된 가입이 발생하면 서비스에서 아주 치명적인 문제가 발생하기 때문에 백엔드단에서 중복 검증과 중복 방지 로직을 작성해야 한다.

프론트에서도, 백에서도 검증해야 한다!

  1. 엔티티 @Column(unique = true)
```java
@Entity
@Getter
@Setter
public class User {

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

    @Column(unique = true)
    private String username;

    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;
}
```
  1. 리포지토리 & 서비스

    public interface UserRepository extends JpaRepository<User, Long> {
    
        boolean existsByUsername(String username);
    }
    public void joinProcess(JoinDTO joinDTO) {
        // db에 이미 동일한 username을 가진 회원이 존재하는지 확인
        boolean isDuplicate = userRepository.existsByUsername(joinDTO.getUsername());
        if (isDuplicate) {
            return;
        }
    
        ...


가입 불가 문자 정규식 처리***

아이디, 비밀번호에 대한 정규식 처리가 필요하다.


8. DB기반 로그인 검증 로직

image




[!note] 궁금증

  1. 현재 서비스에서 user.setRole("ROLE_ADMIN")으로 설정하는데, .requestMatchers("/admin").hasRole("ADMIN")이 동작하는 이유는 무엇일까?

    ROLE_권한명 에서 선행되는 ROLE_ 의 경우 스프링 시큐리티에서 필수적으로 사용하는 접두사 입니다.

    security config 설정시에는 권한명만 명시하면 자동으로 생성하며, DB 기반 커스텀 인증시 ROLE_이라는 접두사를 필수적으로 붙여야하기 때문에 DB 저장시 접두사를 붙여 저장을 합니다. (DB에서 붙이지 않고 UserDetails에서 처리를 진행하셔도 되지만 편의상 DB에 접두사를 함께 저장합니다.)

    추가로 ROLE_ 접두사를 변경하시고 싶으면 아래 메소드를 @Bean으로 등록하시면 됩니다.

    @Bean
    static GrantedAuthorityDefaults grantedAuthorityDefaults() {
      return new GrantedAuthorityDefaults("MYPREFIX_");
    }

    https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/config/core/GrantedAuthorityDefaults.html

  2. role을 ENUM으로 관리하는 것이 좋을까?

seungriyou commented 9 months ago

3. 세션

9. 세션 사용자 정보

원래는 별도로 서비스를 만들어야 하나, 컨트롤러에서 간단히 확인했다.

  • 세션 현재 사용자 ID
```java
String id = SecurityContextHolder.getContext().getAuthentication().getName();
```


10. 세션 설정 (소멸, 중복 로그인, 고정 보호)

로그인 정보

사용자가 로그인을 진행한 뒤 사용자 정보SecurityContextHolder에 의해서 서버 세션에 관리된다.

세션 아이디사용자에게 쿠키로 반환된다.

이때 세션에 관해 세션의 소멸 시간, 아이디당 세션 생성 개수를 설정하는 방법에 대해서 알아보자.

JWT의 경우, 세션에서 관리하지 않는 stateless 방법이므로 해당되지 X


세션 소멸 시간 설정

세션 타임아웃 설정을 통해 로그인 이후 세션이 유지되고 소멸하는 시간을 설정할 수 있다.

세션 소멸 시점은 서버에 마지막 특정 요청을 수행한 뒤 설정한 시간 만큼 유지된다. (기본 시간 1800초)

application.yml

server:
  servlet:
    session:
      timeout: 90m  # default: 1800 (1800s)


다중 로그인 설정

https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html

동일한 아이디로 다중 로그인을 진행할 경우에 대한 설정은 세션 통제를 통해 진행한다.

이때, Security Config에서 sessionManagement() 메소드를 사용한다.

// 다중 로그인 관련
http
        .sessionManagement((auth) -> auth
                .maximumSessions(1)                 // 하나의 아이디에 대한 다중 로그인 허용 개수
                .maxSessionsPreventsLogin(true));   // true: 초과 시 새로운 로그인 차단 / false: 초과 시 기존 세션 하나 삭제


[!note] 나는 중복 로그인이 가능하다…! 이거 뭐지?

https://youtu.be/SsdDnI3bHcI?si=hb1khvb5C149iwgC


세션 고정 보호

image

해커가 세션 쿠키를 탈취하면 admin 권한을 가질 수 있게 된다.

세션 고정 공격을 보호하기 위한 로그인 성공시 세션 설정 방법은 sessionManagement() 메소드의 sessionFixation() 메소드를 통해서 설정할 수 있다.


seungriyou commented 9 months ago

4. 기타 (CSRF, InMemory, 계층 권한)

11. CSRF Enable 설정 방법

CSRF란?

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


개발 환경에서는 Security Config 클래스를 통해 csrf 설정을 disable 설정하였다.

배포 환경에서는 csrf 공격 방지를 위해 csrf disable 설정을 제거하고 추가적인 설정을 진행해야 한다.

[!note] Security Config에 관련 설정을 하지 않으면 default로 enable 설정이 된다.

따라서 로그인을 해보면 진행되지 않는다.


배포 환경에서 진행사항

security config 클래스에서 csrf.disable() 설정을 진행하지 않으면 자동으로 enable 설정이 진행된다. enable 설정시 스프링 시큐리티는 CsrfFilter를 통해 POST, PUT, DELETE 요청에 대해서 CSRF 토큰 검증을 진행한다.

  1. security config 클래스에서 csrf.disable() 구문을 삭제한다.

  2. POST 요청에서 설정하기

    • mustache 기준

      • 서버 측에서 받은 csrf 토큰을 다시 보내주어야 한다.

        <form action="/loginReceiver" method="post" name="loginForm">
            <input type="text" name="username" placeholder="아이디"/>
            <input type="password" name="password" placeholder="비밀번호"/>
            <input type="hidden" name="_csrf" value="{{_csrf.token}}"/>
            <input type="submit" value="로그인"/>
        </form>
      • application.yml: spring.mustache.servlet.expose-request-attributes=true 설정 추가

    • ajax 요청 시 (비동기)

      HTML 구획에 아래의 요소를 추가한다.

      <meta name="_csrf" content="{{_csrf.token}}"/>
      <meta name="_csrf_header" content="{{_csrf.headerName}}"/>
      • ajax 요청 시 위의 content 값을 가져온 후 함께 요청한다.
      • XMLHttpRequest 요청 시 setRequestHeader를 통해 _csrf, _csrf_header Key에 대한 토큰 값 넣어 요청한다.
  3. GET 방식 로그아웃을 진행할 경우

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

    • security config

      @Bean
      public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
      
          http
                  .logout((auth) -> auth.logoutUrl("/logout")
                          .logoutSuccessUrl("/"));
      
          return http.build();
      }
    • 로그아웃용 컨트롤러 따로 생성

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


API 서버의 경우, csrf.disable() 해도 괜찮다!

앱에서 사용하는 API 서버의 경우 보통 세션을 STATELESS로 관리하기 때문에 스프링 시큐리티 csrf enable 설정을 진행하지 않아도 된다.

JWT 등을 사용한다면 stateless


12. InMemory 방식 유저 정보 저장

토이 프로젝트를 진행하는 경우 또는 시큐리티 로그인 환경이 필요하지만 소수의 회원 정보만 가지며 데이터베이스라는 자원을 투자하기 힘든 경우회원가입 없는 InMemory 방식으로 유저를 저장하면 된다.

이 경우 InMemoryUserDetailsManager 클래스를 통해 유저를 등록하면 된다.

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/in-memory.html


1~5장에서 작성한 코드를 기반으로, SecurityConfig에 다음과 같은 스프링 빈을 추가한다.

@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);
    }
}


13. HttpBasic

두 가지 로그인 방식

  1. formLogin (지금까지)
  2. httpBasic (이번에 다룰 것!)
    • Http Basic 인증 방식은 아이디와 비밀번호를 Base64 방식으로 인코딩한 뒤 HTTP 인증 헤더에 부착하여 서버측으로 요청을 보내는 방식이다.
    • 페이지가 굳이 필요하지 않고, 브라우저에서 바로 헤더에 아이디 & 비밀번호를 넣어서 인증한다.

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/basic.html


기존의 .formLogin() 부분을 삭제하고, 다음을 추가한다.

// httpBasic 방식
http
        .httpBasic(Customizer.withDefaults());


14. Role Hierarchy (계층 권한)

https://docs.spring.io/spring-security/reference/servlet/authorization/architecture.html

권한 A, 권한 B, 권한 C가 존재하고 권한의 계층은 “A < B < C”라고 설정을 진행하고 싶은 경우 RoleHierarchy 설정을 진행할 수 있다.