이전에 Spring Security의 Architecture와 매우 가볍게 일부 사용법을 다뤄보았다.
이번엔 UserDetailsService를 깊게 알아보자.
User Details Service 상세
위 그림엔 User Details Service만 나와있지만 상세하게 보면 인터페이스가 더 구분된다.
- UserDetailsService는 이름으로 사용자를 검색하는 역할을 하는 인터페이스로 인증을 완료하는데 필요하다.
- UserDetailsManager는 사용자에 대한 CRUD 작업을 담당하는 인터페이스이다.
두 인터페이스는 인터페이스 분리 원칙(ISP: Interface Segregation Principle)을 지킨다.
이를 각각 구현함으로써 유연성이 향상된다.
- UserDetails는 Spring Security의 사용자를 설명한다.
- GrantedAuthority는 사용자의 권한을 정의한다.
UserDetails 형태
우리는 Spring Security가 사용자를 이해할 수 있도록 구현 및 관리해야 하며 그 형태는 UserDetails이다.
UserDetails 인터페이스는 아래의 모습을 가진다.
public interface UserDetails extends Serializable {
String getUsername();
String getPassword();
Collection<? extends GrantedAuthority> getAuthorities();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
- getUsername(), getPassword()는 각각 사용자 이름과 비밀번호를 반환한다.
이후 5개 method는 사용자가 Application 자원에 접근할 수 있는 권한을 부여하는 데 활용한다.
- getAuthorities()는 사용자에게 부여된 권한 그룹을 반환한다.
- isAccountNonExpired()는 계정 만료에 대한 동작을 다룬다.
- isAccountNonLocked()는 계정을 잠그는 것에 대한 동작을 다룬다.
- isCredentialsNonExpried()는 자격 증명 만료에 대한 동작을 다룬다.
- isEnabled()는 계정 비활성화에 대한 동작을 다룬다.
GrantedAuthority 형태
권한에 대한 내용을 GrantedAuthority가 담당한다.
사용자는 하나 이상의 권한을 가지고 있어야 하므로 GrantedAuthority는 필수이다.
이를 활용해 사용자마다 권한을 알맞게 부여해야 한다.
public interface GrantedAuthority extends Serializable {
String getAuthority();
}
UserDetails 활용
이제 간단하게 UserDetails를 다뤄보자.
- Builder를 활용해 다루는 방법
- UserDetails는 클래스를 구현해 다루는 방법
이 있다.
먼저 Builder를 활용하는 경우는 아래와 같다.
UserDetails u = User.withUsername("hello")
.password("1234")
.authorities("read", "write")
.accountExpired(false)
.disabled(true)
.build();
다음으로 클래스를 활용하는 경우는 아래와 같다.
@Entity
public class User {
@Id
private int id;
private String username;
private String password;
private String authority;
}
public class SecurityUser implements UserDetails {
private final User user;
public SecurityUser(User user) {
this.user = user;
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> user.getAuthority());
}
...
}
User 클래스에 직접 UserDetails를 구현할 수도 있지만 역할을 분리하는 것이 좋으므로 위의 2개 클래스로 나눈다.
UserDetailsService 형태
UserDetails로 사용자 정보를 다루는 것을 보았으니 이제 이를 관리해 보자.
해당 작업은 UserDetailsService가 담당한다.
UserDetailsService는 아래의 모습이다.
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
앞서 말했듯이 해당 인터페이스는 이름으로 사용자를 검색하는 역할만 하므로 하나의 method만 가진다.
lodaUserByUsername(String username) method는 인자로 받은 username의 세부정보를 DB 등에서 찾아 반환한다.
만약 일치하는 username이 없으면 UsernameNotFoundException을 반환한다.
UserDetailsService 구현
위 내용들을 바탕으로 Application을 실행시켜 보자. 구조는 아래와 같다.
먼저 User model이다.
import java.util.Collection;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class User implements UserDetails {
private final String username;
private final String password;
private final String authority;
public User(String username, String password, String authority) {
this.username = username;
this.password = password;
this.authority = authority;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> authority);
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
위에선 역할을 분리하는 것이 좋다고 작성했지만 지금은 동작만 확인할 것이기에 model에 UserDetails를 구현했다.
다음으로 UserDetailsService이다.
import java.util.List;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
public class InMemoryUserDetailsService implements UserDetailsService {
private final List<UserDetails> users;
public InMemoryUserDetailsService(List<UserDetails> users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return users.stream()
.filter(u -> u.getUsername().equals(username))
.findFirst()
.orElseThrow(() -> new UsernameNotFoundException("not found"));
}
}
users 정보를 가지고 있다가 loadUserByUsername을 통해 사용자를 찾아 상세 정보를 반환한다.
다음은 config이다.
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.example.demo.model.User;
import com.example.demo.services.InMemoryUserDetailsService;
@Configuration
public class ProjectConfig {
@Bean
UserDetailsService userDetailsService() {
UserDetails user = new User("hello", "1234", "read");
List<UserDetails> users = List.of(user);
return new InMemoryUserDetailsService(users);
}
@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
동작을 확인하기 위한 UserDetails user를 생성한 후 InMemoryUserDetailsService에 인자로 보낸다. 이후 받은 반환값을 반환한다.
마지막으로 controller이다.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello!";
}
}
이제 프로젝트를 실행한 후 올바르게 sign in 하면 "Hello!"를 볼 수 있다.
참고로 잘못된 값을 입력하면 아래의 모습이 보인다.
동작 과정을 정리하면 다음과 같다.
- UserDetails를 구현한 User model 생성
- "hello", "1234"를 가진 user를 UserDetails 인스턴스로 생성
- user 정보를 InMemoryUserDetailsService로 넘기면서 UserDetailsService가 사용자 정보를 관리하도록 함
- 로그인 요청을 받으면 loadByUsername() method를 통해 사용자를 확인하고 알맞은 값 return
실제로 아래와 같이 loadUserByUsername에 username을 출력하는 코드를 삽입해 보면
입력한 Username의 값이 출력된다.
UserDetailsManager 구현
앞서 UserDetailsManager는 사용자에 대한 CRUD 작업을 담당하는 인터페이스라고 설명했다.
실제로 UserDetailsManager의 모습은 아래와 같다.
public interface UserDetailsManager extends UserDetailsService {
void createUser(UserDetails user);
void updateUser(UserDetails user);
void deleteUser(String username);
void changePassword(String oldPassword, String newPassword);
boolean userExists(String username);
}
위 코드를 보면 UserDetailsService를 상속받고 있으며 이로써 UserDetailsManager를 활용해 User와 관련된 더 많은 작업을 유연하게 확장하고 추가할 수 있다.
UserDetailsService에 InMemoryUserDetailsService가 있듯이 UserDetailsManager에도 InMemoryUserDetailsManeger가 있다.
하지만 실제로 Application을 만들 때엔 DB를 사용하는 것이 일반적이므로 이번엔 JdbcUserDetailsManager를 사용해 볼 것이다.
사용법은
- Database, Table, Data 추가
- xml 추가
- application.properties에 DB 정보 추가
- JdbcUserDetailsManager(DataSource) 활용
이다.
1. Database, Table, Data 추가
Database명은 본인이 정하면 된다.
기본적으로 사용자의 정보를 담는 Table 명은 users이며 username, password, enabled는 필수로 있어야 한다.
CREATE TABLE IF NOT EXISTS `spring`.`users` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NOT NULL,
`password` VARCHAR(45) NOT NULL,
`enabled` INT NOT NULL,
PRIMARY KEY (`id`));
INSERT INTO `spring`.`users` (username, password, enabled) VALUES ('hello', '1234', '1');
2. xml 추가
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
<scope>runtime</scope>
</dependency>
</dependencies>
3. application.properties에 DB 정보 추가
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/spring?serverTimezone=Asia/Seoul
spring.datasource.username={username}
spring.datasource.password={password}
spring.sql.init.mode=always
4. JdbcUserDetailsManager(DataSource) 활용
@Configuration
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
return new JdbcUserDetailsManager(dataSource);
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
이후 실행시키면 된다.
'Java > Spring' 카테고리의 다른 글
Spring Security - SSCM (2) | 2024.06.15 |
---|---|
Spring Security - PasswordEncoder (0) | 2024.06.14 |
Spring Security - Spring Security 겉핥기 (0) | 2024.06.11 |
Spring Security - Spring Security를 공부하는 이유 (1) | 2024.06.04 |