Spring/스프링 시큐리티 인 액션

[3장] 사용자 관리

꾸준함. 2025. 5. 18. 22:46

주의

이 책은 Spring Security 5 버전을 기준으로 작성되었으므로, Spring Boot 3.X 버전에서는 일부 클래스가 더 이상 사용되지(deprecated) 않을 수 있습니다.

 

1. 스프링 시큐리티의 인증 구현

  • 빨간색 테두리가 있는 두 상자는 기본적으로 두 구성 요소 UserDetailsService와 PasswordEncoder
    • 두 구성 요소는 종종 `사용자 관리 부분`이라고 말하는 흐름의 일부분을 처리
    • 이 단원에서 UserDetailsService 및 PasswordEncoder는 사용자 세부 정보와 자격 증명을 직접 처리하는 구성 요소

 

https://jaimemin.tistory.com/2700

 

  • 사용자 관리를 위해서는 UserDetailsService 및 UserDetailsManager 인터페이스를 이용
    • UserDetailsService는 사용자 이름으로 사용자를 검색하는 역할하며 해당 작업은 프레임워크가 인증을 완료하는 데 반드시 필요한 유일한 작업
    • UserDetailsManager는 대부분의 애플리케이션에 필요한 사용자 추가, 수정 및 삭제 작업을 추가함

 

  • SOLID 원칙의 인터페이스 분리 원칙의 훌륭한 예시, 인터페이스를 분리하면 앱에 필요 없는 동작을 구현하도록 프레임워크에서 강제하지 않기 때문에 유연성이 향상됨
    • 사용자를 인증하는 기능만 필요한 경우 UserDetailsService 계약만 구현하면 필요한 기능을 제공할 수 있음
    • 사용자를 관리하려면 UserDetailsService 및 UserDetailsManager 구성 요소에 사용자를 나타내는 방법이 필요

 

  • 스프링 시큐리티에서 사용자는 사용자가 수행할 수 있는 작업을 나타내는 이용 권리의 집합을 가짐
    • 사용자가 수행할 수 있는 작업을 GrantedAuthority 인터페이스로 나타내며 이를 종종 권한이라고 지칭하며 하나 이상의 권한을 가짐

 

https://bluesparrow.tistory.com/32

 

2. 사용자 기술하기

  • 스프링 시큐리티에서 사용자 정의는 UserDetails 계약을 준수해야 함
    • UserDetails 계약은 스프링 시큐리티가 이해하는 방식으로 사용자를 나타냄
    • 애플리케이션에서 사용자를 기술하는 클래스는 프레임워크가 이해할 수 있도록 해당 인터페이스를 구현해야 함

 

UserDetails 계약의 정의 이해하기

 

 

public interface UserDetails extends Serializable {
// 앱 사용자가 수행할 수 있는 작업을 GrantedAuthority 인스턴스의 컬렉션으로 반환
Collection<? extends GrantedAuthority> getAuthorities();
// 사용자 자격 증명을 반환하는 메서드
String getPassword();
String getUsername();
// 사용자 자격 증명을 반환하는 메서드
// 사용자 계정을 필요에 따라 활성화 또는 비활성화하는 네 메서드
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
// 사용자 계정을 필요에 따라 활성화 또는 비활성화하는 네 메서드
}
view raw .java hosted with ❤ by GitHub

 

  • getUsername() 및 getPassword() 메서드는 각각 사용자 이름과 암호를 반환하며 반환된 사용자 이름과 암호는 앱에서 인증 과정에 사용되고 계약에서 인증과 관련된 유일한 세부 정보
  • 나머지 다섯 메서드는 모두 사용자가 애플리케이션의 리소스에 접근할 수 있도록 권한을 부여하기 위한 것
  • 일반적으로 앱은 사용자가 애플리케이션에서 의미 있는 작업을 수행하도록 허용해야 함
    • i.g. 사용자는 데이터를 읽고, 쓰고, 삭제할 수 있어야 함
    • 사용자에게 작업을 수행할 이용 권리가 있거나 없다고 말하며 사용자가 가진 이용 권리를 나타내는 것이 권한이며 getAuthorities() 메서드는 사용자에게 부여된 권한의 그룹을 반환하도록 구현

 

  • UserDetails 계약을 보면 사용자는 다음과 같은 작업을 할 수 있음
    • 계정 만료
    • 계정 잠금
    • 자격 증명 만료
    • 계정 비활성화

 

  • 애플리케이션의 논리에서 이러한 사용자 제한을 구현하려면 isAccountNonExpired(), isAccountNonLocked(), isCredentialNonExpired(), 그리고 isEnabled() 메서드를 재정의해서 true를 반환하게 해야 함
    • 모든 애플리케이션에서 특정 조건에 계정이 만료되거나 잠기는 것이 아니므로 애플리케이션에서 이러한 기능 구현이 필요 없을 경우 단순하게 네 메서드가 true를 반환하게 하면 됨

 

GrantedAuthority 계약 살펴보기

  • 앞서 설명했다시피 사용자에게 허가된 작업을 권한이라고 지칭
  • 권한은 사용자가 애플리케이션에서 수행할 수 있는 작업을 나타냄
    • i.g. 어떤 애플리케이션에서 일부 사용자는 특정 정보를 읽을 수만 있지만 다른 사용자는 데이터를 변경할 수도 있음
    • 애플리케이션은 사용자가 필요로 하는 권한이 애플리케이션의 기능 요구 사항에 따라 다른 유형의 사용자를 구분해야 하며 스프링 시큐리티에서는 GrantedAuthority 인터페이스로 권한을 나타냄

 

  • GrantedAuthority 인터페이스는 사용자 세부 정보의 정의에 이용되며 사ㅏ용자에게 허가된 이용 권리를 나타냄
    • 사용자는 권한이 하나도 없거나 여러 권한을 가질 수 있지만, 일반적으로 하나 이상의 권한을 가짐

 

public interface GrantedAuthority extends Serializable {
String getAuthority();
}
view raw .java hosted with ❤ by GitHub

 

  • 권한을 생성하려면 나중에 권한 부여 규칙을 작성할 때 참조할 수 있게 해당 이용 권리의 이름만 찾으면 됨
    • i.g. 사용자는 애플리케이션이 관리하는 레코드를 읽거나 삭제할 수 있으며 이러한 작업에 부여한 이름을 바탕으로 권한 부여 규칙을 작성함

 

  • SimpleGrantedAuthrotiy 클래스로 GrantedAuthority 형식의 변경이 불가능한 인스턴스를 생성할 수 있음
    • 인스턴스를 만들 때는 권한 이름을 지정해야 함

 

GrantedAuthority g1 = () -> "READ";
GrantedAuthority g2 = new SimpleGrantedAuthority("READ");
view raw .java hosted with ❤ by GitHub

 

최소한의 UserDetails 구현 작성

  • 아래 코드는 DummyUser라는 클래스로 최소한의 사용자 기술을 구현하는 예제
    • 해당 클래스로 UserDetails 계약의 메서드를 구현하는 방법을 볼 수 있음
    • 해당 클래스의 인스턴스는 사용자명이 "bill"이고 암호는 "12345"이며 "READ" 권한이 있는 한 사용자를 나타냄

 

public class DummyUser implements UserDetails {
@Override
public String getUsername() {
return "bill";
}
@Override
public String getPassword() {
return "12345";
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> "READ");
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
view raw .java hosted with ❤ by GitHub

 

  • 실제 애플리케이션에서는 다른 사용자를 나타내는 인스턴스를 생성할 수 있도록 클래스를 작성해야 함
    • 이 경우 아래 코드 예제와 같이 클래스가 사용자 이름과 암호를 특성으로 포함하도록 정의해야 함

 

public class SimpleUser implements UserDetails {
private final String username;
private final String password;
private final String authority;
public SimpleUser(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;
}
}
view raw .java hosted with ❤ by GitHub

 

빌더를 이용해 UserDetailss 형식의 인스턴스 만들기

  • 애플리케이션에서 클래스를 하나 이상 선언해도 되지만, User 빌더 클래스로 사용자를 나타내는 인스턴스를 간편하게 얻을 수 있음
  • org.springframework.security.core.userdetails 패키지의 User 클래스는 UserDetails 형식의 인스턴스를 간단하게 만드는 방법이며 해당 클래스로 UserDetails의 변경이 불가능한 인스턴스를 만들 수 있음
    • 최소한 사용자명과 암호가 필요하고 사용자 이름은 빈 문자열이 아니어야 함

 

UserDetails u = User.withUsername("bill")
.password("12345")
.authorities("read", "write")
.accountExpired(false)
.disabled(true)
.build();
view raw .java hosted with ❤ by GitHub

 

  • User.withUsername(String username) 메서드는 User 클래스에 중첩된 빌더 클래스 UserBuilder의 인스턴스를 반환함
  • 빌더를 만드는 다른 방법은 UserDetails의 다른 인스턴스에서 시작하는 것
    • 아래 예제의 첫 번째 행은 사용자 이름을 뭄ㄴ자열로 지정하는 것으로 시작해서 UserBuilder를 만듦
    • 아래 예제에 나온 모든 빌더를 이용해 UserDetailss 계약으로 표현되는 사용자를 얻을 수 있음
    • 빌더 파이프라인의 끝에서 build() 메서드를 호출하며, 별도로 암호 인코딩 함수를 지정한 경우 이를 적용해서 암호를 인코딩한 뒤 UserDetails의 인스턴스를 구성한 후 반환함

 

User.UserBuilder builder1 = User.withUsername("bill"); // 주어진 사용자 이름으로 사용자 생성
UserDetails u1 = builder1
.password("12345")
.authorities("read", "write")
.passwordEncoder(p -> encode(p))
.accountExpired(false)
.disabled(true)
.build();
// 기존의 UserDetails 인스턴스에서 사용자를 만들 수도 있음
User.UserBuilder builder2 = User.withUserDetails(u);
UserDetails u2 = builder2.build();
view raw .java hosted with ❤ by GitHub

 

사용자와 연관된 여러 책임 결합

  • 실제 시나리오는 기존에 설명한 예제보다 더 복잡할 때가 많고 한 사용자가 여러 책임을 갖는 것이 일반적
  • 사용자를 데이터베이스에 저장할 경우 애플리케이션에 지속성 엔티티를 나타내는 클래스가 필요
  • 또는 다른 시스템에서 웹 서비스를 통해 사용자를 가져오면 사용자 인스턴스를 나타내는 데이터 전송 객체가 필요
  • 간단하고 일반적인 첫 번째 사례로 SQL 데이터베이스 테이블에 사용자를 저장한다고 가정하고 예제를 간단하게 만들기 위해 사용자마다 하나의 권한 부여했음
    • 이 클래스에는 JPA 어노테이션, getter, setter가 포함되어 있고, 그중 getUsername() 및 getPassword()는 모두 UserDetails 계약의 메서드를 재정의함
    • getAuthority() 메서드는 String을 반환하고 getAuthorities() 메서드는 Collection을 반환하며 getAuthority() 메서드는 클래스의 단순한 getter이고 getAuthorities()는 UserDetails 인터페이스의 메서드를 구현함
    • 단순한 예제 코드이므로 이렇게 작성했지만 다른 엔티티에 대한 관계를 추가할 경우 복잡도가 올라가는 바람직하지 않은 코드

 

@Entity
public class User implements UserDetails {
@Id
private int id;
private String username;
private String password;
private String authority;
@Override
public String getUsername() {
return this.username;
}
@Override
public String getPassword() {
return this.password;
}
public String getAuthority() {
return this.authority;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> this.authority);
}
// 중략
}
view raw .java hosted with ❤ by GitHub

 

  • 코드가 복잡한 이유는 두 책을 혼합했기 때문
    • 애플리케이션에 두 책임이 필요한 것은 맞지만, 모두 한 클래스에 넣을 필요는 없음
    • User 클래스를 장식하는 SecurityUser라는 별도의 클래스를 정의함에 따라 책임을 분리할 수 있음
    • SecurityUser 클래스는 UserDetails 계약을 구현하고 이를 이용해 사용자를 스프링 시큐리티 아키텍처에 연결하고 User 클래스에는 JPA 엔티티 책임만 남아 있음
    • SecurityUser 클래스로 User 엔티티 클래스를 장식하고 스프링 시큐리티 계약에 필요한 코드를 추가해서 JPA 엔티티에 코드를 섞어 결과적으로 여러 다른 작업을 구현하지 않도록 했음

 

@Data
@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());
}
// 중략
}
view raw .java hosted with ❤ by GitHub

 

3. 스프링 시큐리티가 사용자를 관리하는 방법 지정

 

UserDetailsService 계약의 이해

 

public interface UserDetailsService {
UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}
view raw .java hosted with ❤ by GitHub

 

  • 사용자 이름이 고유하다고 가정했을 때 인증 구현은 loadUserByUsername(String username) 메서드를 호출해 주어진 사용자 이름을 가진 사용자의 세부 정보를 얻음
    • 해당 메서드가 반환하는 사용자는 UserDetails 계약의 구현
    • 사용자 이름이 존재하지 않으면 메서드가 UsernameNotFoundException을 투척

 

https://livebook.manning.com/book/spring-security-in-action/chapter-3/

 

UserDetailsService 계약 구현

  • 시스템이 어떻게 작동하는지와 관계없이 스프링 시큐리티에 필요한 것은 사용자 이름으로 사용자를 검색하는 구현을 제공하는 것
  • 아래 예제에서는 사용자의 메모리 내 목록을 이용하는 UserDetailsService를 작성하며 InMemoryUserDetailsManager 구현을 이용

 

// User 클래스는 불변
// 인스턴스를 만들 때 세 특성의 값을 지정하며 이러한 값은 나중에 변경 불가
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;
}
// 인스턴스를 만들 때 지정한 이름의 GrantedAuthority 객체만 포함하는 목록 반환
@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;
}
}
public class InMemoryUserDetailsService implements UserDetailsService {
// 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("User not found")); //
}
}
view raw .java hosted with ❤ by GitHub

 

  • loadUserByUsername(String username) 메서드는 주어진 사용자 이름으로 사용자의 목록을 검색하고 원하는 UserDetails 인스턴스를 반환하고 주어진 사용자 이름이 발견되지 않으면 UsernameNotFoundException 예외가 발생함
  • 이제 이 구현을 UserDetailssService로 이용할 수 있으며 아래 예제 코드에는 이를 구성 클래스에 빈으로 추가한 뒤 한 사용자를 등록하는 방법을 소개

 

@Configuration
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService() {
UserDetails u = new User("john", "12345", "read");
List<UserDetails> users = List.of(u);
return new InMemoryUserDetailsService(users);
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
view raw .java hosted with ❤ by GitHub

 

UserDetailsManager 계약 구현

  • UserDetailsManager 인터페이스는 UserDetailsService 계약을 확장하고 메서드를 추가함
  • 스프링 시큐리티가 인증을 수행하려면 UserDetailsService 계약이 필요한데, 일반적으로 애플리케이션에는 사용자를 관리하는 기능이 필요하고 대부분의 앱은 최소한 새 사용자를 추가하거나 기존 사용자를 삭제할 수 있어야 함
  • 이때는 스프링 시큐리티에 정의된 더 구체적인 인터페이스인 UserDetailsManager를 구현하는데, UserDetailsManager는 UserDetailsService를 확장하고 개발자가 구현할 작업을 좀 더 포함하고 있음


public interface UserDetailsManager extends UserDetailsService {
void createUser(UserDetails var1);
void updateUser(UserDetails var1);
void deleteUser(String var1);
void changePassword(String var1, String var2);
boolean userExists(String var1);
}
view raw .java hosted with ❤ by GitHub

 

사용자 관리에 JdbcUserDetailsManager 이용

  • InMemoryUserDetailsManager 외에 다른 UserDetailManager인 JdbcUserDetailsManager도 자주 이용함
  • JdbcUserDetailsManager는 SQL 데이터베이스에 저장된 사용자를 관리하며 JDBC를 통해 데이터베이스에 직접 연결함
    • 이처럼 JdbcUserDetailsManager는 데이터베이스 연결과 관련한 다른 프레임워크나 사양으로부터 독립적일 수 있음

 

  • 아래 예제는 데이터베이스 한 개와 테이블을 두 개 생성하고 JdbcUserDetailsManager 이용 방법을 보여주는 데모 애플리케이션
    • 해당 에제에서 데이터베이스 이름은 spring으로 지정하고 테이블 이름은 각각 users 및 authorities로 지정 (이러한 이름은 JdbcUserDetailsManager가 인식하는 기본 테이블명)
    • users 테이블의 목적은 사용자 레코드를 저장하는 것이며 JdbcUserDetailsManager 구현은 users 테이블에 사용자 이름, 암호, 그리고 사용자 활성화 여부를 저장하는 세 컬럼이 있다고 가정
    • 편의를 위해 라이브러리 종속성 추가는 생략

 

https://livebook.manning.com/concept/spring/jdbcuserdetailsmanager

 

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`));
CREATE TABLE IF NOT EXISTS `spring`.`authorities` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NOT NULL,
`authority` VARCHAR(45) NOT NULL,
PRIMARY KEY (`id`));
INSERT IGNORE INTO `spring`.`authorities` VALUES (NULL, 'john', 'write');
INSERT IGNORE INTO `spring`.`users` VALUES (NULL, 'john', '12345', '1');
view raw .sql hosted with ❤ by GitHub

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
String usersByUsernameQuery = "select username, password, enabled from spring.users where username = ?";
String authsByUserQuery = "select username, authority from spring.authorities where username = ?";
var userDetailsManager = new JdbcUserDetailsManager(dataSource);
userDetailsManager.setUsersByUsernameQuery(usersByUsernameQuery);
userDetailsManager.setAuthoritiesByUsernameQuery(authsByUserQuery);
return userDetailsManager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
view raw .java hosted with ❤ by GitHub

 

사용자 관리에 LdapUserDetailsManager 이용

  • 스프링 시큐리티는 LDAP용 UserDetailsManager 구현도 제공하며, JdbcUserDetailsManager보다는 덜 이용되지만 사용자 관리를 위해 LDAP 시스템을 통합해야 할 때 유용함


@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public UserDetailsService userDetailsService() {
// 컨텍스트 소스를 생성해 LDAP 서버의 주소 지정
var cs = new DefaultSpringSecurityContextSource("ldap://127.0.0.1:33389/dc=springframework,dc=org");
cs.afterPropertiesSet();
LdapUserDetailsManager manager = new LdapUserDetailsManager(cs);
// 사용자 이름 매퍼를 설정해 LdapUserDetailsManager에 사용자를 검색하는 방법 지시
manager.setUsernameMapper(
new DefaultLdapUsernameToDnMapper("ou=groups", "uid"));
// 앱이 사용자를 검색하는 데 필요한 그룹 검색 기준 설정
manager.setGroupSearchBase("ou=groups");
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
view raw .java hosted with ❤ by GitHub

 

참고

스프링 시큐리티 인 액션

 

반응형