[Spring] Security 기초(with JPA)

merci·2023년 4월 10일
1

Spring

목록 보기
15/21

레퍼런스
https://docs.spring.io/spring-security/site/docs/5.2.11.RELEASE/reference/html/
https://godekdls.github.io/Spring%20Security/contents/

스프링 프로젝트 생성

Spring Security를 의존성에 추가한다.

JPA를 사용하기 위한 yaml 설정

spring:
  jpa:
    open-in-view: true
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true
      default_batch_fetch_size: 100 # in query 자동

폴더 생성

컨트롤러 생성

@RestController
@RequiredArgsConstructor
public class HelloController {
    
    @GetMapping("/")
    public ResponseEntity<?> hello(){
        
    return ResponseEntity.ok().body("ok");
    }
}

이후 요청을 하면 시큐리티가 요청을 가로채서 시큐리티 화면이 나온다.

서버 실행시 터미널에 패스워드가 있다.

yaml에서 시큐리티 패스워드를 변경할 수 있다.

  security:
    user:
      name: ssar
      password: 1234 # 패스워드

유저네임은 user로 하고 패스워드를 입력하면 접속이 된다.

이렇듯 시큐리티를 추가하게 되면 요청을 시큐리티가 가로채는데 이 과정을 자세히 알아보자.




스프링 시큐리티

스프링 기반의 보안 프레임워크로서 애플리케이션에서 인증, 인가, 보안 등의 기능을 제공한다.
시큐리티를 공부하기 위해서는 기본적인 스프링 프레임워크를 이해하고 넘어가자.

스프링은 스레드로 나눠져 있으므로 해당 요청의 context는 개별적인 스레드로 분리 되어 있다.
자바의 상태 변수를 여러개의 스레드가 공유하게 되면 상태는 다른 스레드에 영향을 주게 된다.

동기화문제를 막기 위해서는 스레드에 안전하도록 상태를 불변하게 만들거나 함수형 프로그래밍으로 설계 또는 세마포어를 이용한다.
세마포어를 사용하면 스레드 간의 동기화를 구현하거나 스레드에 안전하게 바꿀 수 있다.

SecurityContextHolder

SecurityContextHolder는 인증과 인가정보를 보유하며 SecurityContext인스턴스를 스레드 로컬 변수에 저장한다.
다중 스레드 환경인 스프링에서 시큐리티를 이용하면
시큐리티는 인증, 인가정보를 스레드에 안전하도록 관리하게 된다.

SecurityContext는 요청 또는 세션의 수명 동안 사용자의 정보를 저장하고 액세스하는 방법을 제공하고 실행중인 스레드와 연결되므로 애플리케이션의 어느부분에서도 쉽게 엑세스가 가능하다.

SecurityContext는 유저오브젝트 / 권한 / 패스워드등의 정보를 가진 Authentication를 보유하는데 시큐리티는 요청을 필터에서 확인해 Authentication객체를 생성하고 SecurityContext에 저장한다.

시큐리티는 인증과 권한을 처리하기 위해서 이러한 패턴(프로토콜)을 이용해서 가져야할 타입(Authentication)을 강제시킨다.

이후 요청이 완료되면 SecurityContext을 삭제하여 보안을 강화한다.

Authentication객체에는 다음과 같은 객체가 들어간다.

Pricipal 객체

인증된 사용자를 나타내는 객체를 의미한다.
UserDetailsService에서 반환된 UserDetails객체가 Pricipal객체가 된다.
주로 사용자의 아이디나 이메일이 들어간다.

Credentials 객체

인증을 위해 사용되는 자격 증명 정보를 나타내는 객체를 의미한다.
보통 패스워드나 액세스 토큰등이 Credentials객체가 된다.

Authorities 객체

인증된 사용자의 권한 정보를 나타내는 객체를 의미한다.
보통 권한 문자열 또는 권한 객체의 컬렉션이 Authorities객체로 사용된다.
UserDetails객체가 반환될 때 권한 정보를 GrantedAuthority 컬렉션으로 반환하면 이 객체가 Authorities객체가 된다.

시큐리티에 사용될 모델 생성

스프링 공식 문서를 참고하여 만든다.

package shop.mtcoding.securityapp.model;

import java.time.LocalDateTime;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import javax.persistence.Table;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity // Hibernate가 관리 - 영속 비영속 준영속
@Table(name = "user_tb")
@Getter
@NoArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private String email;
    private boolean status;

    private LocalDateTime createdAt; // db에는 timestamp로 변경되어 들어감
    private LocalDateTime updateAt;

    @PrePersist // insert시 동작 / 비영속 -> 영속
    public void onCreate(){
        this.createdAt = LocalDateTime.now();
    }

    @PreUpdate // update시 동작 
    public void onUpdate(){
        this.updateAt = LocalDateTime.now();
    }

    @Builder
    public User(Long id, String username, String password, String email, 
    			boolean status, LocalDateTime createdAt,
            LocalDateTime updateAt) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.email = email;
        this.status = status;
        this.createdAt = createdAt;
        this.updateAt = updateAt;
    }
}

LocalDateTimetimestamp와는 다르게 날짜와 시간을 다루는 다양한 메소드를 제공한다.
@CreationTimestamptimestamp의 시간을 초기화하므로 LocalDateTime 타입을 넣기위해서 PrePersist같은 어노테이션을 이용한다.

EntityListener

JPA 에서 엔티티의 라이프사이클을 감지하여 이벤트에 대한 콜백메소드를 정의하기 위해 사용되는 인터페이스이다.
엔티티의 생성, 수정, 삭제 등의 이벤트 시점에 추가적인 로직을 처리할 수 있게 하는 목적을 가진다.

엔티티를 DB에 적용하기 전후에 @EntityListener의 다음과 같은 어노테이션을 이용해서 커스텀 콜백을 요청할 수 있다.

@PrePersist : 새로운 엔티티에 대해 persist가 호출되기 전
@PostPersist : 새로운 엔티티에 대해 persist가 호출된 후
@PreUpdate : 엔티티 업데이트 작업 전
@PostUpdate : 엔티티가 업데이트된 후
@PreRemove : 엔티티가 제거되기 전
@PostRemove : 엔티티가 삭제된 후
@PostLoad : Select조회가 일어난 직후에 실행

Config 설정 ( HttpSecurity )

HttpSecurity를 사용해서 시큐리티 설정을 커스텀 해보자

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

// Configuration + Bean -> IoC 에 생성
@Configuration
public class SecurityConfig {

    @Bean
    BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }   

BCryptPasswordEncoder는 단방향 해시 함수로 랜덤 솔트를 이용해 비밀번호를 암호화한다.
입력받은 비밀번호를 랜덤 솔트와 함께 저장하므로 Authentication 객체에 솔트 정보가 포함된다. JWT를 반환하고 다음 요청시에 해당 토큰에서 같은 솔트 정보를 꺼내 해쉬함수로 비교를 한다.
DB에 저장될 때도 솔트와 함께 해시된 패스워드가 저장되는데 시큐리티가 로그인할때 해당 DB에 저장된 동일한 솔트를 이용해서 비밀번호를 비교하는 작업을 진행한다.

SecurityFilterChain을 Bean으로 등록하게 되면 시큐리티는 등록된 필터체인을 이용해서 보안을 처리하게 된다.

    // 시큐리티 설정을 비활성화 - 커스텀 설정으로 변경
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {        
        http.csrf().disable();
        // CSRF 토큰을 숨겨두어서 보안검사
        // ssr은 기본적으로 csrf 토큰을 이용하지 않지만 적용할 수 있다.

시큐리티에서 기본적으로 제공하는 CSRF기능을 비활성화 하게 되면 악성 스크립트 공격(계좌이체)을 보호하는 기능을 사용하지 않게 된다.
개발 기간동안 post man 을 이용해서 요청을 하므로 보안 기능을 비활성화 한다.

인증 / 인가 설정

        http.formLogin()  // form 인증 설정
        		// loginForm에서 받을 파라미터
                .loginPage("/loginForm")
                .usernameParameter("username")
                .passwordParameter("password")
                // 로그인데이터를 받아 로그인을 진행하는 URL - post
                .loginProcessingUrl("/login") 
  				// 시큐리티는 인증이 필요하면 인증페이지로 리다이렉션한다.
  				// 이전 페이지 정보를 기억하고 있다가 다시 연결해준다.
                // .defaultSuccessUrl("/") // -> 인증 성공후 리다이렉션되는 주소
                // .defaultSuccessUrl("/", true); // true 붙이면 강제 리다이렉션
                .successHandler((req, resp, authentication)->{
                    System.out.println("디버그 : 로그인이 완료되었습니다.");
                    resp.sendRedirect("/");
                }) // 로그 기록 
                .failureHandler((req, resp, exception)->{
                    System.out.println("디버그 : 로그인 실패 -> " + exception.getMessage());
                }); // 에러 로그
       
        // 권한 필터 설정 
        http.authorizeRequests((authorize)->{
        	// 로그인이 필요한 페이지 설정
            authorize.antMatchers("/users/**").authenticated()
            .antMatchers("/manager/**").access("hasRole('ADMIN') or hasRole('MANAGER')")
            // .antMatchers("/admin/**").hasAnyRole("ADMIN", "MANAGER")
            .antMatchers("/admin/**").hasRole("ADMIN")            
            .anyRequest().permitAll();   
        });
        return http.build();
    }
}

시큐리티는 기본적으로 인증이 필요할때 인증이 필요한 데이터만 Lazy전략으로 가져오고 인가가 필요할때 세부데이터를 가져온다.
효율적으로 데이터를 조회하는 것처럼 보이지만 특정 항목을 엑세스할 때 느리게 로드되는 경우가 발생해 LazyInitializationException가 발생할 수 있다.

이러한 문제를 해결하기 위해 시큐리티는 OSIV를 이용한다.

OSIV(Object-Session-Interoperability)

OSIV는 ORM 프레임워크에서 사용하는 패턴으로 객체와 세션을 연동하여 처리하는 기술이다.
인증과 권한 정보를 엑세스할 때 OSIV를 사용하여 객체와 세션을 연동하여 처리한다.

스프링에서 OSIV를 활성화하기 위한 yaml설정을 한다.

spring:
  jpa:
    open-in-view: true

OSIV가 Lazy로딩 문제를 해결하지만 여러 단점이 존재한다.

  1. 요청시 Hibernate 세션으로 연동 처리를 하지만 동기화 문제로 성능이 느려질 수 있다.

  2. 하나의 요청에서 여러개의 Hibernate 세션을 사용하는 경우 예외 발생시 모든 세션을 롤백해야하는 문제 발생

  3. 예외 발생시 Hibernate 세션이 닫히므로 데이터베이스 락이 발생할 수 있다.

  4. 데드락 발생 시 트랜잭션으로 롤백을 해야한다.

이러한 LAZY 로딩의 문제를 해결하기 위해 JOIN FETCH절을 이용한 JPQL작성한다.

JOIN FETCH

JOIN은 기본적으로 LAZY 로딩을 이용한다. Fetch와 함께 이용하면 연관된 엔티티를 Eager 전략으로 접근하게 된다.

Fetch 만 추가하여 Eager 전략으로 변경해 한번에 데이터를 가져온다.

String jpql = "SELECT o FROM Order o JOIN o.customer c WHERE o.id = :orderId";
String jpql = "SELECT o FROM Order o JOIN FETCH o.orderItems WHERE o.id = :orderId";

서비스 로직

서비스의 역할은
1. 트랜잭션 관리
2. 영속성 객체 변경감지
3. RequestDTO 요청받기
4. 비지니스 로직 처리하기
5. ResponseDTO 응답하기

요청을 받을 DTO 생성

public class UserRequest {

    @Getter @Setter
    public static class JoinDTO {
        private String username;
        private String password;
        private String email;
        private String role;

        public User toEntity(){
            return User.builder()
                    .username(username)
                    .password(password)
                    .email(email)
                    .role(role)
                    .status(true)
                    .build();
        }
    }
}

해당 DTO를 가져와 회원가입 로직을 작성

최초 회원가입시 해시 암호화가 되어야 하므로 BCryptPasswordEncoder 해시함수를 의존한다.
JPA를 이용해야 하므로 JPA를 상속한 UserRepository를 의존한다.

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    @Transactional
    public UserResponse.JoinDto 회원가입(UserRequest.JoinDTO joinDTO){
        String rawPassword = joinDTO.getPassword();
        String encPassword = passwordEncoder.encode(rawPassword); // 60Byte
        joinDTO.setPassword(encPassword);
        // toEntity로 객체를 리턴해 객체를 저장한다.
        User userPS = userRepository.save(joinDTO.toEntity());
        // 생성자로 리턴
        return new UserResponse.JoinDto(userPS);
    }
}

응답 DTO는 createdAt 필드를 추가하고 현재 시간은 변환해 넣는다.
모델 생성시 @PrePersist가 발동해 시간이 들어간다.

@PrePersist // insert시 동작 / 비영속 -> 영속
public void onCreate(){
    this.createdAt = LocalDateTime.now();
}
// ----------------
this.createdAt = MyDateUtils.toStringFormat(user.getCreatedAt());

public static String toStringFormat(LocalDateTime localDateTime){
    return localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}

컨트롤러 작성

mustache를 이용해서 간단하게 회원가입 / 로그인 폼을 만든다.

    @PostMapping("/join")
    public ResponseEntity<?> join(UserRequest.JoinDTO joinDTO){
        UserResponse.JoinDTO data = userService.회원가입(joinDTO);
        ResponseDTO<?> responseDTO = new ResponseDTO<>().data(data);
        return ResponseEntity.ok().body(responseDTO);
    }

응답할 DTO를 오버로딩해 생성한다.

@Getter @Setter
public class ResponseDTO<T> {
    private Integer status;
    private String msg; // 제목
    private T data; // 상세내용

    public ResponseDTO() {
        this.status = 200;
        this.msg = "성공";
    }

    public ResponseDTO<?> data(T data){
        this.data = data;
        return this;
    }

    public ResponseDTO<?> fail(Integer status, String msg, T data){
        this.status = status;
        this.msg = msg;
        this.data = data;
        return this;
    }
}

회원가입을 하면 아래와 같은 json을 바디로 리턴받는다

{
  "status": 200,
  "msg": "성공",
  "data": {
    "id": 1,
    "username": "ssar",
    "email": "ssar@nate.com",
    "role": "USER",
    "createdAt": "2023-04-16 18:53:32"
  }
}

이후 로그인 작업을 진행해보자.

UserDetailsService

시큐리티에서 UserDetailsService인테페이스는 인증처리에 사용된다.
loadUserByUsername를 구현해서 UserDetails인터페이스를 구현한 객체를 반환한다. UserDetails는 시큐리티에서 사용되는 사용자정보를 나타내는 인터페이스로 인증과 권한정보를 보유한다.

Authentication객체가 생성될 때 Principal객체가 필요한데 시큐리티를 준수하게 되면 UserDetails가 리턴되어 Principal객체로 들어가 Authentication객체가 생성된다.

@RequiredArgsConstructor
@Service
public class MyUserDetailsService implements UserDetailsService{

    private final UserRepository userRepository;

    // /login + Post + FormUrlEncoded + username,password 모두 성립하면
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    	// Optional으로 NPE를 방지한다.
        Optional<User> userOP = userRepository.findbyUsername(username);
        if(userOP.isPresent()){
            return new MyUserDetails(userOP.get());
        }else{
            return null;
        }
    }    
}

JPA를 상속해서 객체를 조회하는 쿼리를 작성한다.

public interface UserRepository extends JpaRepository<User, Long>{    
    @Query("select u from User u where u.username = :username")
    Optional<User> findbyUsername(@Param("username") String username);
}

UserDetails

반환되는 UserDetails는 여러 메소드를 재정의한다.

@Getter
public class MyUserDetails implements UserDetails{
    private User user;
    
    public MyUserDetails(User user) {
        this.user = user;
    }

	// 권한 정보를 반환
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(()-> "ROLE_"+user.getRole());
        return authorities;
    }

	// 토큰 기반 인증일 경우 null 반환
    @Override    
    public String getPassword() {
        return user.getPassword();
    }

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


    // 아래의 옵션들로 Spring Security가 발생시킨다. 
    // false일 경우 인증을 허용하지 않고 사용자는 권한을 얻지못해 엑세스를 못함
    @Override
    public boolean isAccountNonExpired() {
    	// 토큰 만료 확인하는 코드 필요
        return true;
    }

	// 계정이 잠겨있는지 확인 / 사용자 직접 or 비밀번호 틀리면
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
	
    // 비밀번호 유효기간 -> 변경 요청
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 계정 활성화 / 시큐리티는 사용자가 인증하도록 허용한다.
    // false 일경우
    @Override
    public boolean isEnabled() {
        return user.getStatus();
    }
    
}

로그인 + 디버그

디버깅을 위한 yaml설정

logging:
  level:
    '[shop.mtcoding.securityapp]': DEBUG # DEBUG 레벨부터 에러 확인할 수 있게 설정하기
    '[org.hibernate.type]': TRACE # 콘솔 쿼리에

시큐리티 config 설정으로 /login일때 로그인을 하므로 회원가입시 입력한 정보에 따라 다음과 같은 디버그 결과를 받게 된다.

post man으로 로그인시 쿠키를 반환 받는다.

유저 권한이 필요한 /users/** 주소를 요청하면 시큐리티가 인증된 유저로 판단해 허용한다.

    @GetMapping("/users/{id}")
    public ResponseEntity<?> userCheck(@PathVariable Long id, @AuthenticationPrincipal MyUserDetails myUserDetails) {
        String username = myUserDetails.getUsername();
        String role = myUserDetails.getUser().getRole();
        return ResponseEntity.ok().body(username + " : "+ role);
    }

profile
작은것부터

0개의 댓글