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'
}
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을 의미)
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");
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);
}
}
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를 리턴.
@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객체 반환.
@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검증
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를 분리함.
<!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>
타임리프를 통해 간단하게 정의
@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를 넣어줌.