[Spring]

[Spring Security] 시큐리티 환경 설정과 로그인

danhan 2023. 1. 18. 20:44
백기선님의 스프링부트 개념과 활용 기본편 강의를 듣고, 정리한 글입니다.
스프링부트 시큐리티 환경을 설정하고 회원가입과 로그인까지 완료했습니다.

 

환경 설정

MySQL 세팅

먼저 MySQL Workbench에서 MySQL DB와 사용자를 생성한다.

create user 'cos'@'%' identified by 'cos12345';
GRANT ALL PRIVILEGES ON *.* TO 'cos'@'%';
create database security;
use security;

ERROR 1819 (HY000): Your password does not satisfy the current policy requirements.

프로젝트 생성

인텔리제이의 Spring Initializer를 사용해 프로젝트를 생성한다.

Spring Boot DevTools, Lombok, Spring Data JPA, MySQL Driver, Spring Security, Mustache, Spring Web 7개의 dependencies를 추가한다.

강의에선 maven으로 프로젝트를 만들었는데, 강의 듣고 나서 시큐리티를 적용할 실프로젝트에 맞춰 gradle로 만들었다.

application.properties 설정

server.port = 8080
server.servlet.context-path=/
server.servlet.encoding.charset=UTF-8
server.servlet.encoding.enabled=true
server.servlet.encoding.force=true

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

spring.jpa.hibernate.ddl-auto=create
spring.jpa.properties.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

spring.jpa.show-sql=true
// mustache 사용시 기본으로 templates는 prefix로, 
// .mustache는 suffix로 설정되어 있어 생략할 수 있다.
mvc:
    view:
      prefix: /templates/
      suffix: .mustache

mustache 실습

controller 패키지를 만들고, IndexController.java를 생성한다.

// src/main/resources/application/IndexController

package com.example.springsecurity1.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller // view를 리턴하겠다
public class IndexController {

    // localhost:8080/와 localhost:8080 주소를 넣어준다.
    @GetMapping({"","/"})
    public String index() {
        
        return "index";
    }
}

Mustache 기본폴더는 src/main/resources/ 이다.

mustache의 view resolver는 기본으로 prefix는 templates로, suffix는 .mustache로 설정되어 있다.

따라서 기본적으로 return "index"하면 src/main/resources/templates/index.mustache를 찾는다.

 

resoruces/templates에 login.html을 생성한다.

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/loginProc" method="post">
	<input type="text" name="username" />
	<input type="password" name="password" />
	<button>로그인</button>
</form>
</body>
</html>

이 파일은 suffix가 html이기 때문에 mastache가 찾지 못한다.

html 파일을 인식할 수 하도록 MustacheViewResolver설정을 바꿔줘야 한다.

config 패키지 안에 WebMvcConfig.java 파일을 만들어 configureViewResolvers를 overriding하고 suffix를 .html로 변경한다.

package com.cos.securityex01.config;

import org.springframework.boot.web.servlet.view.MustacheViewResolver;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

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

이렇게 바꾸면 mastache가 .html을 인식하게 된다.

 

정상 작동 되는지 프로젝트를 실행해 확인해본다.

console 창 중간에 나오는 security password를 클립보드에 저장해둔다.

Using generated security password: 5cdfa908-4821-454e-90c8-f66ae8bdc0c6

주소창에 localhost:8080을 친다.

그러면 자동으로 localhost:8080/login 로그인 페이지로 연결된다.

 

스프링부트 스큐리티를 의존성으로 설정하게 되면 홈페이지로 들어온 모든 주소가 막히고 인증이 필요한 페이지가 되기 때문이다.

 

로그인 페이지에 아이디는 user로 하고, 아까 저장해둔 security password을 비밀번호로 넣으면 원래 원하던 localhost:8080 페이지가 뜬다.

시큐리티 설정

IndexController에 다른 메서드들을 추가한다.

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

가 만들고 싶은 메서드 형태이지만 아직 연결할 user.html이 없으므로 일단은 @ResponseBody를 붙여 메서드를 만든다.

package com.example.springsecurity1.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller // view를 리턴하겠다
public class IndexController {

    // localhost:8080/와 localhost:8080 주소를 넣어준다.
    @GetMapping({"","/"})
    public String index() {
        // Mustache 기본폴더는 src/main/resources/ 이다.
        // 뷰리졸버 설정할 때 templates를 prefix로,
        // .mustache를 suffix로 잡으면 설정이 끝난다.
        return "index";
    }

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

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

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

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

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

    @GetMapping("/joinProc")
    public @ResponseBody String joinProc() {
        return "회원가입 완료됨";
    }

}

localhost:8080으로 들어가 잘 동작하는지 확인한다.

아까 로그인한게 남아있다면 localhost:8080/logout로 로그아웃하면 된다.

➕ 서버를 재시작했다면 security password가 달라졌으니 다시 console 창에서 찾아서 넣어준다.

Security 권한 설정

user/admin/manager에 따라 접근할 수 있는 페이지 권한을 제한하자.

config 패키지에 SecurityConfig를 만들고 filterChain을 만들어준다.

강의대로 따라하면 deprecated error가 뜨니까 아래 코드로 넣는다.

package com.example.springsecurity1.config;

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.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity //스프링 시큐리티 필터가 스프링 필터페인에 등록된다.
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();

        http.authorizeHttpRequests()
                .requestMatchers("/user/**").authenticated()
                .requestMatchers("/manager/**").hasAnyAuthority("ROLE_ADMIN", "ROLE_MANAGER")
                .requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
                .requestMatchers("/**").permitAll();
        return http.build();
    }
}

WebSecurityConfigurerAdapter Deprecated 해결

로그인 페이지

사용자에게 입력받아 로그인하는 페이지를 만들자.

templates에 loginFrom.html을 만든다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/loginProc" method="post">
  <input type="text" name="username" placeholder="username"/>
  <input type="password" name="password" placeholder="password"/>
  <button>로그인</button>
</form>
</body>
</html>

/loginForm으로 요청이 들어왔을때 loginForm.html로 연결될 수 있도록 IndexController와 SecurityConfig를 수정한다.

// IndexController

@Controller
public class IndexController {

		...

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

		...
}

// SecurityConfig

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();

        http.authorizeHttpRequests()
                .requestMatchers("/user/**").authenticated()
                .requestMatchers("/manager/**").hasAnyAuthority("ROLE_ADMIN", "ROLE_MANAGER")
                .requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
                .requestMatchers("/**").permitAll();
								.and() // 이 부분 추가
                .formLogin()
                .loginPage("/loginForm");
        return http.build();
    }
}

회원가입 페이지

로그인 페이지와 동일하게 회원가입 페이지를 만들자.

templates에 joinFrom.html을 만든다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/loginProc" method="post">
  <input type="text" name="username" placeholder="username"/>
  <input type="password" name="password" placeholder="password"/>
  <button>로그인</button>
</form>
</body>
</html>

/joinForm으로 요청이 들어왔을때 joinForm.html로 연결되고, 회원가입에 성공하면 /join으로 이어지도록 수정한다.

불필요한 /joinProc 코드는 삭제한다.

// IndexController

@Controller
public class IndexController {

		...

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

		@PostMapping("/join")
    public @ResponseBody String join(User user) {
        System.out.println(user);
        return "join";
    }

		...
}

추가로 로그인 페이지에는 회원가입 페이지로 넘어갈 수 있도록 <a href="/joinForm">회원가입을 아직 하지 않으셨나요?</a>를 넣는다.

 

이제 localhost:8080/user에 접속하면 localhost:8080/loginForm으로 연결되어 loginForm.html이 나오고, 페이지 하단에 ‘회원가입을 아직 하지 않으셨나요?’를 클릭하면 localhost:8080/joinForm으로 넘어가면서 joinForm.html이 뜬다.

시큐리티 회원가입

이제 시큐리티를 이용해 회원가입을 해보자

먼저 IndexController에 UserRepository를 넣어주고, Role을 정해준 user을 저장해준다.

public class IndexController {

    @Autowired
    private UserRepository userRepository;
		
		....
	
		@PostMapping("/join")
    public @ResponseBody String join(User user) {
        System.out.println(user);

        user.setRole("ROLE_USER"); // 회원가입은 잘 되지만 시큐리티로 로그인 불가능
        userRepository.save(user); // why? password 암호화가 안되었기 때문
        return "join";
    }
}

이렇게 되면 user가 회원가입은 잘 되지만 시큐리티로 로그인할 수는 없다.

password 암호화가 안되었기 때문이다.

BCryptPasswordEndcoder로 비밀번호를 암호화해야한다.

 

먼저 SecurityConfig에 BcryptPasswordEncoder를 빈으로 등록한다.

➕ @Bean 애노테이션으로 빈을 등록하면 해당 메서드의 리턴되는 오브젝트가 IoC로 등록된다.

public class SecurityConfig {
    @Bean
    public BCryptPasswordEncoder encodePwd() {
        return new BCryptPasswordEncoder();
    }
		...
}

아까 userRepository와 마찬가지로 IndexController 상단에 @Autwired해서 bCryptPasswordEncoder를 넣어주고 join 메스드를 수정한다.

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

        return "redirect:/loginForm";
    }

return "redirect:/loginForm"를 하면 loginForm 메서드로 리다이렉트 된다.

 

잘 동작하는지 확인하자.

이제 localhost:8080/user에 접속하면 localhost:8080/loginForm으로 연결되고, 페이지 하단에 ‘회원가입을 아직 하지 않으셨나요?’를 클릭하면 localhost:8080/joinForm으로 넘어간다.

 

정보를 입력하고 회원가입을 하면 DB에 user 정보가 저장되면서 localhost:8080/loginForm으 리다이렉트 된다.

 

 

MySQL Workbench에서 select * from user;를 하면 password가 암호화된 user의 정보가 잘 들어간 걸 확인할 수 있다.

시큐리티 로그인

SecurityConfig에 로그인 주소가 호출되었을 때 시큐리티 로그인이 되도록 코드를 추가한다.

@Configuration
@EnableWebSecurity //스프링 시큐리티 필터가 스프링 필터페인에 등록된다.
public class SecurityConfig {
    @Bean // @Bean을 붙이면 해당 메서드의 리턴되는 오브젝트를 IoC로 등록해준다.
    public BCryptPasswordEncoder encodePwd() {
        return new BCryptPasswordEncoder();
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();

        http.authorizeHttpRequests()
                .requestMatchers("/user/**").authenticated()
                .requestMatchers("/manager/**").hasAnyAuthority("ROLE_ADMIN", "ROLE_MANAGER")
                .requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
                .requestMatchers("/**").permitAll()
                .and()
                .formLogin()
                .loginPage("/loginForm")      // 이부분 추가
                .loginProcessingUrl("/login") // login 주소가 호출되면 시큐리티가 낚아채 대신 로그인해준다.
                .defaultSuccessUrl("/");

        return http.build();
    }
}

loginForm.html도 수정한다.

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

이제 loginForm에서 input 데이터들을 /login으로 post 요청을 보내면 시큐리티가 /login을 낚아채서 로그인한다.

 

config에 auth 패키지를 만들고 PrincipalDetails.java를 만든다.

시큐리티는 /login 주소로 요청이 오면 요청을 낚아채서 로그인을 진행시킨다. 진행이 완료되면 시큐리티 session을 만들고 Security ContextHolder에 세션 정보 저장한다.

 

시큐리티에는 Authentication 타입 객체만 들어갈 수 있다. 그리고 이 Authentication 객체에는 UserDetails 타입만 들어갈 수 있다. 따라서 Authentication 타입 객체에 들어가는 모든 User 객체는 UserDetails 타입이어야한다.

즉, Security Session -> Authentication -> UserDetails 관계다.

PrincipalDetails는 UserDetails를 implements해서 만든다.

package com.example.springsecurity1.config.auth;

import com.example.springsecurity1.model.User;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@Data
public class PrincipalDetails implements UserDetails {

    private User user;

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

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collet = new ArrayList<GrantedAuthority>();
        collet.add(()->{ return user.getRole();});
        return collet;
    }

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

다음으로 PrincipalDetailsService를 만든다.

config/auth 패키지에 들어간다.

 

시큐리티 설정에서 loginProcessingUrl("/login");으로 걸어놨기 때문에 /login 요청이 오면 자동으로 UserDetailsService 타입으로 IoC 되어 있는 loadUserByUsername함수가 실행된다.

정해져있는 규칙 같은 거라고 보면 된다.

 

loadUserByUsername가 UserDetails를 return하면 Sercurity Session이 만들어지면서 UserDetails는 Authentication 내부로, Authentication은 Security Session 내부로 쏙 들어간다.

package com.example.springsecurity1.config.auth;

import com.example.springsecurity1.model.User;
import com.example.springsecurity1.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class PrincipalDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User userEntity = userRepository.findByUsername(username);
        if(userEntity != null) {
            return new PrincipalDetails(userEntity);
        }
        return null;
    }

}

시큐리티 권한 처리

admin과 manager 계정을 만들어주자.

클라이언트에서 회원가입을 해주고, MySQLWorkbench에서 role을 갱신한다.

update user set role = 'ROLE_ADMIN' where id = 2;
update user set role = 'ROLE_MANAGER' where id = 3;

select * from user;

SecurityConfig에 @EnableMethodSecurity를 붙인다.

EnableGlobalMethodSecurity는 deprecated되었으니 EnableMethodSecurity를 쓴다.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {
		...
}

메서드 하나에만 권한 처리를 할 수도 있다.

role을 하나만 설정할 때는 @Secured("ROLE_ADMIN")를, 여러개 설정할 때는 @Secured("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")처럼 hasRole을 사용할 수 있다.