(spring security) jpa를 이용한 간단한 로그인 어플리케이션

jint·2024년 10월 21일

보안

목록 보기
6/15

다음과 같은 종속성을 추가(gradle).

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
	runtimeOnly 'com.mysql:mysql-connector-j'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

mysql workbench를 통해서 다음과 같은 db, table을 생성

DROP DATABASE IF EXISTS spring;
CREATE DATABASE spring;
USE spring;

CREATE TABLE user (
	id INT NOT NULL AUTO_INCREMENT,
    username VARCHAR(45) NOT NULL,
    password TEXT NOT NULL,
    algorithm VARCHAR(45) NOT NULL,
    PRIMARY KEY (id));
    
CREATE TABLE authority (
	id INT NOT NULL AUTO_INCREMENT,
    name VARCHAR(45) NOT NULL,
    user_id INT NOT NULL,
    PRIMARY KEY (id));
    
CREATE TABLE product (
	id INT NOT NULL AUTO_INCREMENT,
    name VARCHAR(45) NOT NULL,
    price VARCHAR(45) NOT NULL,
    currency VARCHAR(45) NOT NULL,
    PRIMARY KEY (id));
    

암호저장할때 bcrypt, scrypt을 지원하기 때문에 algorithm을 통해서 명시하도록 함.

간단한 데이터를 넣기.

“12345”를 generator를 통해 bcrypt로 해싱하면 다음과 같다.

bcrpytEncoder의 기본 cost factor는 10이다(해싱 반복횟수 2^10을 의미)

https://bcrypt.online/

2y$10JnMtKFqbjIieUoGHAFCTI.ZIwno1CMDADQ8qc9BIUXNJoB4IiRc.O

INSERT INTO user (username, password, algorithm) VALUES ("john", "$2y$10$JnMtKFqbjIieUoGHAFCTI.ZIwno1CMDADQ8qc9BIUXNJoB4IiRc.O", "BCRYPT");
INSERT INTO authority (name, user_id) VALUES ("READ", 1);
INSERT INTO authority (name, user_id) VALUES ("WRITE", 1);
INSERT INTO product (name, price, currency) VALUES ("Chocolate", 10, "USD");

application.properties에서 db연결 매개 변수 설정

spring.datasource.url=jdbc:mysql://localhost:3306/spring
spring.datasource.password=<your password>
spring.datasource.username=<your username>

엔터티 작성

user

@Entity
public class User {
    @Id @GeneratedValue
    private int id;

    private String username;
    private String password;

    @Enumerated(EnumType.STRING)
    private EncryptionAlgo algorithm;

    @OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
    private List<Authority> authorities = new ArrayList<>();

    //getter, setter 생략
 }

authority

@Entity
public class Authority {
    @Id @GeneratedValue
    private int id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;

    //getter, setter 생략
}

product

@Entity
public class Product {
    @Id @GeneratedValue
    private int id;

    private String name;
    private double price;

    @Enumerated(EnumType.STRING)
    private Currency currency;

    //getter, setter 생략
}

필요한 리포지토리 생성

UserRepository

@Repository
public interface UserRepository extends JpaRepository<User, Integer> {
    Optional<User> findUserByUsername(String username);
}

나중에 UserDetailsService에서 username을 바탕으로 UserDetails를 찾아오기 때문에 필요.

ProductRepository

public interface ProductRepository extends JpaRepository<Product, Integer> {
}

main화면에서 간단하게 products를 보여주기 위해서 findAll필요.

작업할 내용

encoder로 시큐리티에 있는 BCrypt, SCrypt인코더 등록.

userDetails와 userDetailsService를 필요에 맞게 재정의.

AuthenticationProvider에서 2개의 인코더와 userDetailsService를 인증 구현

AuthenticationProvider를 AuthenticationManager에 등록하기

인코더등록

@Configuration
public class ProjectConfig {

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

    @Bean
    public SCryptPasswordEncoder scryptPasswordEncoder() {
        return new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
    }
}

userDetails

public class CustomUserDetails implements UserDetails {
    private final User user;

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

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getAuthorities().stream()
                .map(authority -> new SimpleGrantedAuthority(authority.getName()))
                .collect(Collectors.toList());
    }
    @Override
    public String getPassword() {
        return user.getPassword();
    }

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

    public User getUser() {
        return user;
    }

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

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

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

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

GrantedAuthority의 단순한 구현체인 SimpleGrantedAuthority를 사용해 getAuthorities구현

나머지 제약조건들은 사용안하기 때문에 true를 리턴.

userDetailsService

@Service
public class JpaUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public JpaUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public CustomUserDetails loadUserByUsername(final String username) throws UsernameNotFoundException {
        User user =  userRepository.findUserByUsername(username).orElseThrow(() -> new UsernameNotFoundException(username));
        return new CustomUserDetails(user);
    }
}

username을 통해 알맞는 userDetails객체 반환.

authenticationProvider

@Component
public class AuthenticationProviderService implements AuthenticationProvider {

    private final JpaUserDetailsService userDetailsService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final SCryptPasswordEncoder scryptPasswordEncoder;

    public AuthenticationProviderService(
            JpaUserDetailsService userDetailsService,
            BCryptPasswordEncoder bCryptPasswordEncoder,
            SCryptPasswordEncoder scryptPasswordEncoder) {
        this.userDetailsService = userDetailsService;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
        this.scryptPasswordEncoder = scryptPasswordEncoder;
    }
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
           String username = authentication.getName();
           String password = authentication.getCredentials().toString();

           CustomUserDetails user = userDetailsService.loadUserByUsername(username);

           if(user.getUser().getAlgorithm().equals("BCRYPT")) {
               return checkPassword(user, password, bCryptPasswordEncoder);
           }
           //SCRYPT사용
           else {
               return checkPassword(user, password, scryptPasswordEncoder);
           }
    }

    private Authentication checkPassword(CustomUserDetails user, String password, PasswordEncoder encoder) {
        if(bCryptPasswordEncoder.matches(password, user.getPassword())) {
            return new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), user.getAuthorities());
        } else {
            throw new BadCredentialsException("Bad credentials");
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

기존 빈으로 등록한 인코더와 userDetailsService를 통해 구체적인 인증을 구현.

User엔터티에 algorithm을 가져와 인코더를 선택해 password검증

authenticationProvider를 authenticationManager에 등록하기

springSecurity in action이 현제 의존성과 버전이 맞지 않아 수정함

@Configuration
public class SecurityConfig {
    private final AuthenticationProviderService authenticationProviderService;

    public SecurityConfig(AuthenticationProviderService authenticationProviderService) {
        this.authenticationProviderService = authenticationProviderService;
    }

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        // AuthenticationManager 설정
        return http.getSharedObject(AuthenticationManagerBuilder.class)
                .authenticationProvider(authenticationProviderService)
                .build();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(
                authorizeRequests -> authorizeRequests.anyRequest().authenticated()
        );
        http.formLogin(
                formLogin -> formLogin.defaultSuccessUrl("/main", true)
        );

        return http.build();
    }
}

WebSecurityConfigurerAdapter가 사라졌기 때문에 configure메서드에서 AuthenticationManagerbuilder를 가져올 수 없었고 HttpSecurity를 받아 defaultSuccessUrl를 설정할 수 없었다.

HttpSecurity의 getSharedObject메서드를 통해 AuthenticationManagerBuilder찾아와 AuthenticationManager를 직접 빈 등록하고 빌드함.

책에서는 메서드 체인을 이용해 filter설정을 했는데 deprecated되었다고 떠서

람다식을 이용해서 filterChain을 설정함

모든 요청에 대해 인증을 요구해야되기 때문에 authorizeRequests.anyRequest().authenticated()을 설정해줌.

처음 코드에서는 ProjectConfig에서 전부 빈으로 등록했는데 AutenticationProvider는 ProjectConfig의 encoder의존성이 필요하고 ProjectConfig는 AutenticationProvider의존성이 필요하기 때문에 사이클이 발생해서 config를 분리함.

main.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Products</title>
    </head>
    <body>
        <h2 th:text="|Hello ${username}!|"></h2>
        <p><a href="/logout">Sign out here</a></p>

        <h2>These are all the products</h2>
        <table>
            <thead>
                <tr>
                    <th>Name</th>
                    <th>Price</th>
                </tr>
            </thead>
            <tbody>
                <tr th:if="${products.empty}">
                    <td colspan="2">No Products Available</td>
                </tr>
                <tr th:each="product : ${products}">
                    <td th:text="${product.name}"></td>
                    <td th:text="${product.price}"></td>
                </tr>
            </tbody>
        </table>
    </body>
</html>

타임리프를 통해 간단하게 정의

MainController

@Controller
public class MainController {

    private final ProductRepository productRepository;

    public MainController(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @GetMapping("/main")
    public String main(Model model, Authentication a) {
        model.addAttribute("products", productRepository.findAll());
        model.addAttribute("username", a.getName());
        return "main";
    }
}

직접 SecurityContext를 SecurityContextHolder를 통해 가져올 필요없이 스프링 부트 프로젝트라면 자동으로 Authentication를 넣어줌.

0개의 댓글