227 lines
11 KiB
Markdown
227 lines
11 KiB
Markdown
아래는 "스프링 시큐리티" 책의 4장에 포함될 "인메모리 인증 및 데이터베이스 연동을 통한 인증", "커스텀 UserDetailsService 구현", 그리고 "비밀번호 암호화와 보안"에 대한 내용입니다. 실무에서의 활용을 고려해 실습 가능하고 명확한 설명을 제공했습니다.
|
|
|
|
---
|
|
|
|
### 4장. 사용자 인증 구현
|
|
|
|
#### 4.1 In-Memory 인증 설정
|
|
|
|
**In-Memory 인증**은 스프링 시큐리티에서 사용자 정보를 애플리케이션 메모리에 저장하고 관리하는 가장 간단한 인증 방식입니다. 별도의 데이터베이스나 외부 시스템 없이 빠르게 설정할 수 있어 학습이나 프로토타입 개발에 적합합니다.
|
|
|
|
스프링부트에 스프링 시큐리티를 추가하면 기본적으로 In-Memory 인증이 활성화됩니다. 이 경우 `user`라는 이름의 사용자와 무작위 비밀번호가 생성되며, 콘솔에 출력됩니다. 하지만 실무에서는 사용자를 직접 정의하고 싶을 때가 많습니다. 이를 위해 설정 클래스를 작성할 수 있습니다.
|
|
|
|
##### 설정 예제
|
|
```java
|
|
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.core.userdetails.User;
|
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
|
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
|
import org.springframework.security.web.SecurityFilterChain;
|
|
|
|
@Configuration
|
|
public class SecurityConfig {
|
|
|
|
@Bean
|
|
public UserDetailsService userDetailsService() {
|
|
var user = User.withUsername("john")
|
|
.password("{noop}password123") // {noop}은 암호화 없음을 의미
|
|
.roles("USER")
|
|
.build();
|
|
var admin = User.withUsername("admin")
|
|
.password("{noop}admin123")
|
|
.roles("ADMIN")
|
|
.build();
|
|
return new InMemoryUserDetailsManager(user, admin);
|
|
}
|
|
|
|
@Bean
|
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
|
http
|
|
.authorizeHttpRequests(auth -> auth
|
|
.anyRequest().authenticated())
|
|
.formLogin();
|
|
return http.build();
|
|
}
|
|
}
|
|
```
|
|
위 코드에서 `InMemoryUserDetailsManager`를 사용해 두 명의 사용자(`john`과 `admin`)를 메모리에 등록했습니다. 이들은 각각 `USER`와 `ADMIN` 역할을 가지며, 로그인 시 이를 기반으로 인증됩니다. `{noop}`은 비밀번호 암호화를 사용하지 않음을 의미하며, 이후 암호화 섹션에서 개선 방법을 다룹니다.
|
|
|
|
In-Memory 인증은 간단하지만 사용자 수가 많거나 동적으로 변경될 경우 한계가 있습니다. 이를 해결하기 위해 데이터베이스 연동으로 넘어갑니다.
|
|
|
|
#### 4.2 데이터베이스 연동을 통한 사용자 인증
|
|
|
|
실제 애플리케이션에서는 사용자 정보를 데이터베이스에 저장하고, 이를 기반으로 인증을 처리하는 경우가 많습니다. 스프링 시큐리티는 이를 위해 `UserDetailsService` 인터페이스를 제공하며, 데이터베이스와 연동해 사용자 정보를 조회할 수 있습니다.
|
|
|
|
##### 데이터베이스 설정
|
|
먼저, 사용자 정보를 저장할 테이블을 설계해야 합니다. 예를 들어:
|
|
```sql
|
|
CREATE TABLE users (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
username VARCHAR(50) NOT NULL UNIQUE,
|
|
password VARCHAR(100) NOT NULL,
|
|
enabled BOOLEAN NOT NULL
|
|
);
|
|
|
|
CREATE TABLE authorities (
|
|
username VARCHAR(50) NOT NULL,
|
|
authority VARCHAR(50) NOT NULL,
|
|
FOREIGN KEY (username) REFERENCES users(username)
|
|
);
|
|
```
|
|
위 테이블은 사용자 정보(`users`)와 권한 정보(`authorities`)를 분리해 저장합니다.
|
|
|
|
##### 기본 연동 설정
|
|
스프링 시큐리티는 `JdbcUserDetailsManager`를 통해 데이터베이스와 바로 연동할 수 있습니다.
|
|
```java
|
|
import org.springframework.context.annotation.Bean;
|
|
import org.springframework.context.annotation.Configuration;
|
|
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
|
|
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
|
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
|
import org.springframework.security.provisioning.JdbcUserDetailsManager;
|
|
import org.springframework.security.web.SecurityFilterChain;
|
|
|
|
import javax.sql.DataSource;
|
|
|
|
@Configuration
|
|
public class SecurityConfig {
|
|
|
|
@Bean
|
|
public DataSource dataSource() {
|
|
return new EmbeddedDatabaseBuilder()
|
|
.setType(EmbeddedDatabaseType.H2)
|
|
.addScript("classpath:schema.sql")
|
|
.build();
|
|
}
|
|
|
|
@Bean
|
|
public UserDetailsService userDetailsService(DataSource dataSource) {
|
|
return new JdbcUserDetailsManager(dataSource);
|
|
}
|
|
|
|
@Bean
|
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
|
http
|
|
.authorizeHttpRequests(auth -> auth
|
|
.anyRequest().authenticated())
|
|
.formLogin();
|
|
return http.build();
|
|
}
|
|
}
|
|
```
|
|
`JdbcUserDetailsManager`는 기본적으로 `users`와 `authorities` 테이블을 조회하며, 사용자 이름, 비밀번호, 활성화 여부, 권한 등을 가져옵니다. `schema.sql` 파일에 위의 테이블 생성 쿼리를 추가하면 H2 데이터베이스가 자동으로 설정됩니다.
|
|
|
|
#### 4.3 커스텀 UserDetailsService 구현
|
|
|
|
기본 제공되는 `JdbcUserDetailsManager`는 편리하지만, 테이블 구조나 비즈니스 로직이 복잡할 경우 커스터마이징이 필요합니다. 이때 `UserDetailsService` 인터페이스를 직접 구현해 커스텀 로직을 추가할 수 있습니다.
|
|
|
|
##### 구현 예제
|
|
```java
|
|
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 CustomUserDetailsService implements UserDetailsService {
|
|
|
|
private final UserRepository userRepository;
|
|
|
|
public CustomUserDetailsService(UserRepository userRepository) {
|
|
this.userRepository = userRepository;
|
|
}
|
|
|
|
@Override
|
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
|
UserEntity user = userRepository.findByUsername(username)
|
|
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
|
|
|
return org.springframework.security.core.userdetails.User
|
|
.withUsername(user.getUsername())
|
|
.password(user.getPassword())
|
|
.authorities(user.getRoles().stream().map(Role::getName).toArray(String[]::new))
|
|
.disabled(!user.isEnabled())
|
|
.build();
|
|
}
|
|
}
|
|
```
|
|
위 코드에서:
|
|
- `UserRepository`는 JPA나 MyBatis 같은 ORM으로 데이터베이스에서 사용자 정보를 조회합니다.
|
|
- `loadUserByUsername` 메서드는 사용자 이름을 기반으로 사용자 정보를 가져와 `UserDetails` 객체로 변환합니다.
|
|
- `UserEntity`는 커스텀 엔티티 클래스이며, 역할(`roles`)과 활성화 상태(`enabled`)를 포함합니다.
|
|
|
|
##### 설정에 통합
|
|
```java
|
|
@Configuration
|
|
public class SecurityConfig {
|
|
|
|
@Bean
|
|
public UserDetailsService userDetailsService(UserRepository userRepository) {
|
|
return new CustomUserDetailsService(userRepository);
|
|
}
|
|
|
|
@Bean
|
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
|
http
|
|
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
|
|
.formLogin();
|
|
return http.build();
|
|
}
|
|
}
|
|
```
|
|
이렇게 하면 인증 시 `CustomUserDetailsService`가 호출되어 데이터베이스에서 사용자 정보를 가져옵니다.
|
|
|
|
#### 4.4 비밀번호 암호화와 보안
|
|
|
|
비밀번호를 평문으로 저장하는 것은 보안상 매우 위험합니다. 스프링 시큐리티는 이를 방지하기 위해 비밀번호 암호화를 권장하며, `PasswordEncoder` 인터페이스를 제공합니다.
|
|
|
|
##### PasswordEncoder 소개
|
|
스프링 시큐리티에서 기본으로 사용하는 암호화 방식은 **BCrypt**입니다. BCrypt는 단방향 해시 함수로, 동일한 입력에 대해 항상 다른 출력(솔트 포함)을 생성해 보안을 강화합니다. 주요 특징은 다음과 같습니다:
|
|
- **솔팅(Salting)**: 동일한 비밀번호라도 다른 해시값을 생성.
|
|
- **작업 비용(Work Factor)**: 계산 비용을 조정해 무차별 대입 공격(Brute-force)을 어렵게 함.
|
|
|
|
##### 설정 예제
|
|
```java
|
|
@Configuration
|
|
public class SecurityConfig {
|
|
|
|
@Bean
|
|
public PasswordEncoder passwordEncoder() {
|
|
return new BCryptPasswordEncoder();
|
|
}
|
|
|
|
@Bean
|
|
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
|
|
var user = User.withUsername("john")
|
|
.password(passwordEncoder.encode("password123"))
|
|
.roles("USER")
|
|
.build();
|
|
return new InMemoryUserDetailsManager(user);
|
|
}
|
|
|
|
@Bean
|
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
|
http
|
|
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
|
|
.formLogin();
|
|
return http.build();
|
|
}
|
|
}
|
|
```
|
|
- `passwordEncoder.encode()`로 비밀번호를 암호화합니다.
|
|
- 로그인 시 입력된 비밀번호와 저장된 해시값을 비교하려면 `passwordEncoder.matches()`를 내부적으로 사용합니다.
|
|
|
|
##### 데이터베이스 연동 시 적용
|
|
데이터베이스에 저장할 때는 암호화된 비밀번호를 저장하고, 인증 시 `PasswordEncoder`를 `CustomUserDetailsService`와 함께 사용합니다. 스프링 시큐리티는 이를 자동으로 처리하므로, `UserDetails` 객체에 암호화된 비밀번호만 제공하면 됩니다.
|
|
|
|
##### 추가 보안 팁
|
|
- **다양한 암호화 방식**: `DelegatingPasswordEncoder`를 사용하면 BCrypt 외에 다른 알고리즘(PBKDF2, SCrypt 등)을 지원할 수 있습니다.
|
|
- **비밀번호 정책**: 강력한 비밀번호를 요구하거나 주기적 변경을 유도하세요.
|
|
- **암호화 키 관리**: 외부 설정 파일이나 환경 변수를 통해 암호화 키를 관리하세요.
|
|
|
|
---
|
|
|
|
위 내용은 인증 방식의 기본부터 실무 적용까지 다루며, 비밀번호 암호화로 보안을 강화하는 방법을 설명했습니다. 코드 예제나 특정 부분에 대한 추가 설명이 필요하면 말씀해 주세요! |