Spring Security 기본 구조

byeol·2023년 4월 13일
0

스프링부트 시큐리티 & JWT 강의

최주호님의 위 강의를 듣고 정리합니다.
(프로젝트를 하며 복잡한 로그인 구현 구조에대해
이해할 수 있는 알찬 강의라고 생각합니다.)


환경 설정

spring start io로 검색하면
스프링을 만들 수 있는 사이트가 나옵니다.

  • Maven
  • Java
  • 2.7.10
  • Jar
  • Java 8
  • Dependencies : Lombok, Spring Boot Devtools, Mustache, Spring Security, Spring Data JPA, MySQL Driver

✅ config > WebMvcConfig
이 파일의 용도는 Controller에서 return값이 String인 경우 우리가 의존성을 Mustache로 해놓았기 때문에 .mustache로 가는데 여기로 가지 않고 html파일이 호출되도록 설정하는 곳이다.

WebMvcConfigurer이라는 클래스를 MVC를 쉽게 구현할 수 있는 여러가지 메소드를 제공하는데
여기에서는 configureViewResolver(ViewResolverRegistry registry)라는 메서드를 이용하여
prefix와 suffix, 그리고 인코딩과 디코딩 방식을 추가해주었다.

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        MustacheViewResolver resolver = new MustacheViewResolver();
        resolver.setCharset("UTF-8");
        resolver.setContentType("text/html; charset=UTF-8");
        resolver.setPrefix("classpath:/templates/");
        resolver.setSuffix(".html");

        registry.viewResolver(resolver);
    }
}

✅ login 페이지
내가 /login 페이지를 구현하지 않았어도 security가 /login을 낚아채서 본인의 /login 페이지를 보여준다.

시큐리티 설정

✅config > SecurityConfig

SecurityConfig를 통해서 로그인한 사람은 이 페이지에 접근할 수 있다.
로그인도 하면서 이 권한을 가진 사람만 이 페이지에 접근할 수 있다.
만약 로그인을 하지 않은 상태에서 로그인을 해야하는 페이지에 접근한다면
로그인 페이지로 전환된다.
로그인에 성공하면 이 페이지로 전환된다.

등의 필터를 넣을 수 있는 SecurigyConfig 클래스 파일은 아래와 같습니다.

@Configuration
@EnableWebSecurity//활성화 스프링 시큐리티 필터가 스프링 필터체인에 등록이 됩니다.
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)//secured 어노테이션 활성화 // preAuthorize 어노테이션 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //해당 메서드의 리턴되는 오브젝트를 IoC로 등록해준다.
    @Bean
    public BCryptPasswordEncoder encodePwd(){
        return new BCryptPasswordEncoder();
    }

    protected void configure(HttpSecurity http) throws Exception{
        http.csrf().disable();
        http.authorizeRequests()
                .antMatchers("/user/**").authenticated() // "user"는 로그인을 해야
                .antMatchers("/manager/**").access("hasRole('ROLE_ADMIN')or hasRole('ROLE_MANAGER')")//로그인도 하면서 권한도
                .antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")// 로그인 하면서 권한도
                .anyRequest().permitAll()//설정한 경로 외 모든 경로는 어떤 사용든지 접근할 수 있다.
                .and() // 권한이 없거나 로그인을 하지 않을 때 로그인 페이지로 넘어가게
                .formLogin()
                .loginPage("/loginForm")
                .loginProcessingUrl("/login")// /ㅣlogin 주소가 호출되면 시큐리타가 낚아채서 대신 로그인츨 진행 -> 따라서 우리가 /login을 구현하지 않아도 된다.
                .defaultSuccessUrl("/");//login이 완려되면 메인페이지로
                // 너가 그냥 loginForm에서 로그인에 성공하면 "/"
                // 그러나 어떤 특정 페이지인 "/user"를 요청했다가 거절당해서 로그인 폼으로 돌아오면
               // 로그인 성공하고 나서 "/"이 아닌 너가 요청했던 그 특정 페이지로 돌려준다
               // "/loginForm"->"/"
               //"/user"-> 거절->"/loginForm"->"/user"

    }
}

시큐리티 회원가입

⏺️ 로그인
✅ IndexController의 "/loginForm"

 //ResponseBody로 설정해도 스프링 시큐리티가 해당 주소를 낚아채서
    //json이 나오지 않고 로그인 페이지로 간다.
    //-> 그러나 SecurityConfig 후에는 작동하지 않아 json이 보인다.
    @GetMapping("/loginForm")
    public  String loginForm() {
        return "loginForm";
    }

IndexController에는 "/login"이 없습니다.
"/loginForm"만 있습니다.
"/login"이 없어도 스프링 시큐리티가 구현해주기 때문입니다.
하지만 "login"이 진행될 때 필요한 session에 들어갈 내용 등은 개발자가 구현해야합니다.

물론 초반에는 "/login"으로 해 놓고 스프링 시큐리티를 간접적으로 경험할 수 있습니다.
우리가 login.html을 만들어 놓지 않고 Controller에 GetMapping("/login")을 해 놓아도
스프링 시큐리티가 낚아채서 시큐리티의 로그인 페이지로 넘깁니다.

✅ resources/templates > loginForm.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<form action="/login" method="POST">
    <input type="text" name="username" placeholder="Username"/><br/>
    <input type="password" name="password" placeholder="Password"/><br/>
    <button>로그인</button>
</form>
<a href="/joinForm">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>

✅ application.yml의 DB 설정 부분

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/security?serverTimezone=Asia/Seoul
    username: 
    password: 

코드를 입력하세요

security라는 DB가 필요합니다.
또한 DB에 user table을 추가해야 합니다.
따라서 아래와 같이 User라는 Entity를 만들어주도록 하겠습니다.

✅ model> User
```java
@Entity
@Data
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String username;
    private String password;
    private String email;
    private String role;
    @CreationTimestamp
    private Timestamp createDate;


}

run을 하면
security라는 DB 아래에 user라는 테이블이 위와 같은 컬럼과 속성에 맞게 만들어집니다.


⏺️ 회원가입
✅ resources/templates > loginForm.html

...
<h1>로그인 페이지</h1>
<form action="/login" method="POST">
    <input type="text" name="username" placeholder="Username"/><br/>
    <input type="password" name="password" placeholder="Password"/><br/>
    <button>로그인</button>
</form>
<a href="/joinForm">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>

위와 같이 링크가 걸려있기 때문에 "/joinForm"으로 가는 Controller를 만들어줘야 합니다.
✅ IndexController의 "/joinForm" : 회원가입 페이지로 이동

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

"/joinForm"이 호출되면 joinForm.html인 회원가입을 할 수 있는 페이지로 이동합니다.
✅ resources/templates > joinForm.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>회원가입 페이지</title>
</head>
<body>
<h1>회원가입 페이지</h1>
<form action="/join" method="POST">
    <input type="text" name="username" placeholder="Username"/><br/>
    <input type="password" name="password" placeholder="Password"/><br/>
    <input type="email" name="email" placeholder="Email"/><br/>
    <button>회원가입</button>
</form>
</body>
</html>

위에 보면 회원가입 버튼을 누르면 Post로 "/join"을 호출합니다
"/join"이 호출되면 form으로 받은 데이터를 User DB에 저장하는 과정이 필요합니다.

✅ IndexController의 "/join" : 회원가입하여 user 정보를 user DB에 저장하는 과정


  @Autowired
    private UserRepository userRepository;

   @Autowired
   private BCryptPasswordEncoder bCryptPasswordEncoder;

  @PostMapping("/join")
    public String join(User user) {
        System.out.println(user);
        user.setRole("ROLE_USER");
        String rawPassword = user.getPassword();
        String encPassword = bCryptPasswordEncoder.encode(rawPassword);
        user.setPassword(encPassword);
        userRepository.save(user);
        // 회원가입은 잘됨 비밀번호 =>1234
        //그러나 시큐리티로 로그인할 수 없음
        // 이유는 패스워드가 암호화가 안되었기 때문이다.

        return "redirect:/loginForm";
    }

위에 보면 userRepository.save DB에 insert 할 수 있는 함수가 등장합니다.

✅ repository > UserRepository

//CRUD 함수를 JpaRepository가 들고 있음
//@Repository라는 어노테이션이 없어도 IoC가 된다.
//이유는 JpaRepository를 상속했기 때문에 가능하다
public interface UserRepository extends JpaRepository<User,Integer> {


 //findBy 규칙 ->Username 문법
 //select * from user where username=?
 public User findByUsername(String username);

}

✅ SecuriyConfig : 스프링 시큐리티를 이용하기 위해 패스워드 암호화

@Configuration
@EnableWebSecurity//활성화 스프링 시큐리티 필터가 스프링 필터체인에 등록이 됩니다.
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)//secured 어노테이션 활성화 // preAuthorize 어노테이션 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //해당 메서드의 리턴되는 오브젝트를 IoC로 등록해준다.
    @Bean
    public BCryptPasswordEncoder encodePwd(){
        return new BCryptPasswordEncoder();
    }
    ...

시큐리티 로그인

✅ config > auth > PrincipalDetails
시큐리티가 /login 주소 요청이 오면 낚아채서 로그인을 진행시킵니다.
로그인 진행이 완료가 되면 session을 만들어 줍니다.
시큐리티의 session이 있습니다.
우리가 알고 있는 그 session이며 같은 session 공간인데 시큐리티가 자신만의 session 공간을 갖습니다. (Security ContextHolder)

여기에 들어갈 수 있는 정보는
오브젝트가 정해져 있습니다. => Authentication 타입의 객체
Authentication => User정보가 있어야 됨
User오브젝트타입=> UserDetails 타입 객체

Security Session 영역 => 여기 들어갈 객체 Authentication => 여기에 저장된 User 정보UserDetails

public class PrincipalDetails implements UserDetails {
    //PrincipalDetail Type = UserDatails
    //그러면 PrincipalDetails 객체를 Authentication 객체에 넣을 수 있다.

    private User user;//콤포지션

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

    //해당 User의 권한을 리턴하는 곳!!
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
         //getRole의 반환값이 String이므로
        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole();
            }
        });

        return collect;
    }

    @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;
    }
   
    //계정 만든지 1년 안지났니?
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    //계정 활성화되었니?
    @Override
    public boolean isEnabled() {

        //우리 사이트 1년 동안 회원이 로그인을 안하면
        //휴면 계정으로 만들기
        //user.getLoginDate -> 현재시간 - 로그인 시간 1년 시간을 초과하면
        return true;
    }


}

✅ config > auth > PrincipalDetailsService : 회원인가 확인하고 있으면 Security session 생성

@Service
public class PrincipalDetailsService implements UserDetailsService {

    @Autowired
    UserRepository userRepository;

    //시큐리시 session (Authentication( UserDetails))
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        User userEntity = userRepository.findByUsername(username);
        if(username!=null){
            return new PrincipalDetails(userEntity);
        }
        return null;
    }
}

SecurityConfig에서 loginProcessingUrl("/login")을 걸어두었습니다.
로그인에 성공하면 "/login"을 호출하도록 되어 있는데
"/login" 요청이 오면 자동으로 UserDetailsService 타입으로 IoC되어 있는 객체의loadUserByUsername함수를 실행합니다.
이는 규칙입니다.

loadUserByUsername의 매개변수명이 아주 중요합니다.
String username이라는 변수명은
loginForm.html<input type="text" name="username" placeholder="Username"/><br/> name 속성과 변수명 통일해야합니다!
만약 다르다면 SecurityConfig에서

.loginPage("/loginForm")
.usernameParameter("input의 name 변수명") // 추가됨
.loginProcessingUrl("/login")

위와 같이 usernameParamet라는 메서드를 추가해서 변수명을 명시해줘야 합니다.

시큐리티 권한 처리

권한에 따라 접근할 수 있는 페이지를 글로벌하게 설정할 수도 있지만
글로벌하지 않게 딱 하나의 페이지에 설정하는 방법도 있습니다.

✅ config > SecurityConfig

@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)//secured 어노테이션 활성화 // preAuthorize 어노테이션 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

바로 @EnableGlobalMethodSecurity를 이용하는 방법입니다.

✅ controller > IndexController

 @Secured("ROLE_ADMIN") // 개인적으로 걸 때 //@EnableGlobalMethodSecurity(securedEnabled = true)
    @GetMapping("/info")
    public @ResponseBody String info(){
        return "개인정보";
    }

    @PreAuthorize("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')") // data라는 메소드가 실행되기 직전에 실행
    @GetMapping("/data")
    public @ResponseBody String data(){
        return "데이터정보";
    }
profile
꾸준하게 Ready, Set, Go!

0개의 댓글