뭐라도 끄적이는 BLOG

Custom UserDetailsService & Custom User 본문

SpringFramework/Spring Security

Custom UserDetailsService & Custom User

Drawhale 2023. 7. 10. 07:52

이번에는 사용자가 직접 UserDetailsService를 구현하는 class를 생성하고 Bean으로 등록한다. 그리고 직접 User정보를 데이터베이스에서 가지고와서 인증에 사용되어질 수 있도록 User를 상속한 MyUser를 만들어 반환하는 작업을 진행해 본다. 추가로 인증 성공시와 실패시 이벤트 설정을 어떻게 하는지에 대해서도 살펴본다.

Inmemory UserDetailsService

public class MyUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (username.equals("member"))
            return User.builder()
                    .username("member")
                    .password("member")
                    .roles("USER", "ADMIN")
                    .disabled(false)
                    .build();
        throw new UsernameNotFoundException(username);
    }
}

우선 UserDetailsService를 구현한 클래스를 만들어 Inmemory로 테스트 해본다. MyUserDetailsService 클래스는 loadUserByUsername을 Override한다. 해당 메소드는 Spring Security에서 사용자 정보가 있는지 확인하는 역할을 한다. MyUserDetailsService가 Bean으로 등록되어 잘동작하는지만 살펴보기 위해서 단순히 username이 member면 User.builder로 UserDetails를 생성하여 반환하였다.

 보통은 데이터베이스에서 사용자 정보를 찾아 값이 있는지 확인하고 인증에 사용할 수 있도록 UserDetails타입으로 반환한다.

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize -> {
                    authorize.requestMatchers(HttpMethod.GET, "/").permitAll();
                    authorize.requestMatchers(HttpMethod.GET, "/user/**").hasAnyAuthority("ROLE_USER", "OIDC_USER");
                    authorize.requestMatchers(HttpMethod.GET, "/admin/**").hasRole("ADMIN");
                    authorize.anyRequest().authenticated();
                })
                .csrf(AbstractHttpConfigurer::disable)
                .headers(header-> header.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
                .formLogin(withDefaults())
                .httpBasic(withDefaults());
        return http.build();
    }

    @Bean
    UserDetailsService userDetailsService() {
        return new MyUserDetailsService();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}

Configuration에서 MyUserDetailsService를 Bean으로 등록한다. @Service 어노테이션으로 ComponentScan을 이용하여 Bean으로 등록해도된다.

그리고 패스워드 암호화를 위한 passwordEncoder를 Bean으로 등록해야한다.  NoOpPasswordEncoder는 암호화를 하지 않을때 사용하는 Encoder로 테스트에서만 잠시 이용할뿐 절대로 사용하면 안되는 Encoder이다. 이제 어플리케이션을 실행해서 MyUserDetailsService가 잘 적용되었는지 확인해 볼 수 있다.

Authentication Events (인증 이벤트)

인증에 성공하거나 실패할 때마다 각각 인증 성공이벤트나 인증 실패 이벤트가 발생한다. 이러한 이벤트를 수신하기 위한 방법 2가지를 소개한다.

EventListener

...
    @EventListener
    public void onSuccess(AuthenticationSuccessEvent success) {
        System.err.printf("Success Login %s - %s%n",success.getAuthentication().getClass().getName(), success.getAuthentication().getName() );
    }

    @EventListener
    public void onFailure(AbstractAuthenticationFailureEvent failures) {
        System.err.printf("Success Login %s - %s%n",failures.getAuthentication().getClass().getName(), failures.getAuthentication().getName() );
    }
...

EventListener Annotation이 선언된 각각의 인증 이벤트를 매개변수로 받는 메소드를 이용하여 Event를 받을 수 있다.

ApplicationListener<>

...
    @Bean
    public ApplicationListener<AuthenticationSuccessEvent> successEvent() {
        return event -> {
            System.err.printf("Success Login %s - %s%n",event.getAuthentication().getClass().getName(), event.getAuthentication().getName() );
        };
    }

    @Bean
    public ApplicationListener<AuthenticationFailureBadCredentialsEvent> failureEvent() {
        return event -> {
            System.err.printf("Bad Credentials Login %s - %s%n",event.getAuthentication().getClass().getName(), event.getAuthentication().getName() );
        };
    }
...

ApplicationListener에 Event별로 반환하는 Bean을 이용해서 Event를 받을 수 있다.

두가지 모두 같은 결과를 보여준다.

UserDetailsService Connect DB

이번에는 개발자가 직접 설계한 데이터베이스 테이블로 Spring Security에서 이용할 수 있도록 작업해 볼것이다.

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
annotationProcessor 'org.projectlombok:lombok'

//runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'

데이터 베이스 코드를 신경쓰지 않고 테스트 해보기 위해서 jpa와 lombok을 사용한다. 데이터베이스는 이제 h2에서 mariadb로 변경했다.

MariaDB와 JPA 설정정보를 application.yml에 추가해 준다.

server:
  port: 8080

logging:
  level:
    org.springframework.security: INFO
    org.springframework.jdbc: INFO
spring:
  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    url: jdbc:mariadb://192.168.0.18:3306/test
    username: test
    password: test
  jpa:
    properties:
      hibernate:
        format_sql: true
    defer-datasource-initialization: false
    hibernate:
      ddl-auto: none
    generate-ddl: false
    show-sql: true

Authorities와 Users Entity를 생성 했다. lombok으로 간단하게 사용할 수 있도록 만들었다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "AUTHORITIES")
public class Authorities {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NonNull
    @Column(unique = true)
    private String authority;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "USERS")
public class Users {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NonNull
    @Column(unique = true)
    private String username;

    @NonNull
    private String password;

    @Singular
    @ManyToMany(cascade = CascadeType.MERGE, fetch = FetchType.EAGER)
    @JoinTable(name = "users_authorities", joinColumns = {
            @JoinColumn(name = "USERS_ID", referencedColumnName = "ID")}, inverseJoinColumns = {
            @JoinColumn(name = "AUTHORITIES_ID", referencedColumnName = "ID")})
    private Set<Authorities> authorities;

    @Builder.Default
    private Boolean accountNonExpired = true;
    @Builder.Default
    private Boolean accountNonLocked = true;
    @Builder.Default
    private Boolean credentialsNonExpired = true;
    @Builder.Default
    private Boolean enabled = true;
}

username으로 select 쿼리를 생성하는 findByUsername과 모든 유저를 검색하는 findAll을 가진 Repository를 만든다.

@Repository
public interface UsersRepository extends JpaRepository<Users, Long> {
    @EntityGraph(type = EntityGraph.EntityGraphType.FETCH,
        attributePaths = {"authorities"})
    Optional<Users> findByUsername(String username);
    @EntityGraph(type = EntityGraph.EntityGraphType.FETCH,
            attributePaths = {"authorities"})
    @NonNull
    List<Users> findAll();
}

위 처럼 데이터베이스를 연결하고 데이터베이스에서 사용자 정보를 가지고 올 수 있는 방법이라면 어느것이든 사용할 수 있다. loadUserByUsername에 사용자 정보를 데이터베이스에서 가져와서 UserDetails로 변환할 수 있다면 어떤 방법이든 사용해도 좋다.

MyUserDetailsService에서 데이터베이스에서 유저 정보를 찾아 온다. loadUserByUsername은 인증이 아니라 데이터베이스나 메모리에 저장되어 있는 사용자가 있는지확인하는 용도이다. 확인을 했다면 인증에 사용하기 위해 UserDetails로 변환하여 반환한다.

이전에는 Configuration에서 Bean을 생성했으나 ComponentScan으로 Bean을 생성할 수 있도록 @Service Annotation을 추가했다.

@Slf4j
@Service
@AllArgsConstructor
public class MyUserDetailsService implements UserDetailsService{

    private final UsersRepository usersRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        Optional<Users> userByUsername = usersRepository.findByUsername(username);
        if (userByUsername.isEmpty()) {
            log.error("Could not find user with that username: {}", username);
            throw new UsernameNotFoundException("Invalid credentials!");
        }
        Users user = userByUsername.get();
        if (!user.getUsername().equals(username)) {
            log.error("Could not find user with that username: {}", username);
            throw new UsernameNotFoundException("Invalid credentials!");
        }
        Set<GrantedAuthority> grantedAuthorities = new HashSet<>();
        user.getAuthorities().forEach(authority ->
                grantedAuthorities.add(new SimpleGrantedAuthority(authority.getAuthority())));
        return User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .authorities(grantedAuthorities)
                .disabled(!user.getEnabled())
                .build();
    }
}

SecurityConfiguration에선 이제 더이상 UserDetailsService Bean을 등록하지 않아도 되기 때문에 메소드를 삭제했다. 그리고 passwordEncoder도 BCryptPasswordEncoder로 변경하였다. BCrypt는 blowfish hash Algorithm을 사용하여 비밀번호를 암호화 하는 Encoder로 사용자 비밀번호를 데이터 베이스에 저장 및 사용자 인증시 입력한 패스워드와 저장된 패스워드를 확인할 용도로 사용된다. (패스워드 암호화에 대해서 알고 싶다면 일방향 해시 암호화를 찾아보도록 하자.)

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize -> {
                    authorize.requestMatchers(HttpMethod.GET, "/").permitAll();
                    authorize.requestMatchers(HttpMethod.GET, "/user/**").hasRole("USER");
                    authorize.requestMatchers(HttpMethod.GET, "/admin/**").hasRole("ADMIN");
                    authorize.anyRequest().authenticated();
                })
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(withDefaults())
                .httpBasic(withDefaults());
        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public ApplicationListener<AuthenticationSuccessEvent> successEvent() {
        return event -> System.err.printf("Success Login %s - %s%n",event.getAuthentication().getClass().getName(), event.getAuthentication().getName() );
    }

    @Bean
    public ApplicationListener<AuthenticationFailureBadCredentialsEvent> failureEvent() {
        return event -> System.err.printf("Bad Credentials Login %s - %s%n",event.getAuthentication().getClass().getName(), event.getAuthentication().getName() );
    }
}

MariaDB에서 생성한 table의 DDL이다.

더보기
create table authorities (
    id bigint not null auto_increment,
    authority varchar(255),
    primary key (id)
) engine=InnoDB;

create table users (
    account_non_expired bit,
    account_non_locked bit,
    credentials_non_expired bit,
    enabled bit,
    id bigint not null auto_increment,
    password varchar(255),
    username varchar(255),
    primary key (id)
) engine=InnoDB;

create table users_authorities (
    authorities_id bigint not null,
    users_id bigint not null,
    primary key (authorities_id, users_id)
) engine=InnoDB;

alter table authorities
add constraint uniqueAuthority unique (authority);

alter table users
add constraint uniqueUsername unique (username);

alter table users_authorities
add constraint FK_authorities_id
foreign key (authorities_id)
references authorities (id);

alter table users_authorities
add constraint FK_user_id
foreign key (users_id)
references users (id);

Mariadb에서 사용한 DML이다.

더보기
insert into authorities(authority)
values ( 'ROLE_USER' );
insert into authorities(authority)
values ( 'ROLE_ADMIN' );
insert into authorities(authority)
values ( 'ROLE_DEVELOPER' );

insert into users(username, password, ACCOUNT_NON_EXPIRED, ACCOUNT_NON_LOCKED, CREDENTIALS_NON_EXPIRED, ENABLED)
values ( 'Developer', '$2y$07$ionwU9D46zj0UoZK2d9ki.EjFzzYAtd3CuFnxxM88m1CmpQ..qyw2', true, true, true, true );
insert into users(username, password, ACCOUNT_NON_EXPIRED, ACCOUNT_NON_LOCKED, CREDENTIALS_NON_EXPIRED, ENABLED)
values ( 'Admin', '$2y$07$ionwU9D46zj0UoZK2d9ki.EjFzzYAtd3CuFnxxM88m1CmpQ..qyw2', true, true, true, true );
insert into users(username, password, ACCOUNT_NON_EXPIRED, ACCOUNT_NON_LOCKED, CREDENTIALS_NON_EXPIRED, ENABLED)
values ( 'User', '$2y$07$ionwU9D46zj0UoZK2d9ki.EjFzzYAtd3CuFnxxM88m1CmpQ..qyw2', true, true, true, true );

INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 1, 1 );
INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 1, 2 );
INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 1, 3 );
INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 2, 1 );
INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 2, 2 );
INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 3, 1 );

패스워드는 "pass"문자가 BCrypt로 암호화된 패스워드로 아무도 알아볼 수 없다. (복호화 불가능) 해당 정보를 입력할 방법을 어플리케이션에 따로 만들지 않았으니 DML을 직접 입력해야 한다. 여기까지 코드를 변경하고 데이터베이스를 정보를 추가 하면 로그인 테스트를 해볼 수 있다.

Custom UserDetails

이번엔 loadUserByUsername에서 반환하는 UserDetails를 Custom하여 사용해 볼것이다. loadUserByUsername에서 사용자를 확인하기 위해 데이터베이스에서 사용자의 정보를 가지고 왔을때 해당 사용자의 정보를 인증 뒤에도 사용할 수 있도록 데이터를 추가해주는 작업이라고 생각하면 된다.

먼저 데이터베이스에서 가져오는 Entity에 몇가지 정보를 더 추가해준다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "USERS")
public class Users {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NonNull
    @Column(unique = true)
    private String username;

    @NonNull
    private String password;

    @Singular
    @ManyToMany(cascade = CascadeType.MERGE, fetch = FetchType.EAGER)
    @JoinTable(name = "users_authorities", joinColumns = {
            @JoinColumn(name = "USERS_ID", referencedColumnName = "ID")}, inverseJoinColumns = {
            @JoinColumn(name = "AUTHORITIES_ID", referencedColumnName = "ID")})
    private Set<Authorities> authorities;

    @Builder.Default
    private Boolean accountNonExpired = true;
    @Builder.Default
    private Boolean accountNonLocked = true;
    @Builder.Default
    private Boolean credentialsNonExpired = true;
    @Builder.Default
    private Boolean enabled = true;

    private String firstName;
    private String lastName;
    private String emailAddress;
    private LocalDate birthdate;
}

fistName, lastName, emailAddress, birthdate등 사용자 인증과는 관련없는 사용자 정보를 추가한다. 해당 Entity정보에 따라 데이터베이스 테이블도 변경하였다.

MariaDB에서 생성한 table의 DDL이다.

더보기
create table authorities (
    id bigint not null auto_increment,
    authority varchar(255),
    primary key (id)
) engine=InnoDB;

create table users (
    account_non_expired bit,
    account_non_locked bit,
    credentials_non_expired bit,
    enabled bit,
    id bigint not null auto_increment,
    password varchar(255),
    username varchar(255),
    first_name varchar(50),
    last_name varchar(50),
    email_address varchar(50),
    birthdate datetime,
    primary key (id)
) engine=InnoDB;

create table users_authorities (
    authorities_id bigint not null,
    users_id bigint not null,
    primary key (authorities_id, users_id)
) engine=InnoDB;

alter table authorities
add constraint uniqueAuthority unique (authority);

alter table users
add constraint uniqueUsername unique (username);

alter table users_authorities
add constraint FK_authorities_id
foreign key (authorities_id)
references authorities (id);

alter table users_authorities
add constraint FK_user_id
foreign key (users_id)
references users (id);

Mariadb에서 사용한 DML이다.

더보기
insert into authorities(authority)
values ( 'ROLE_USER' );
insert into authorities(authority)
values ( 'ROLE_ADMIN' );
insert into authorities(authority)
values ( 'ROLE_DEVELOPER' );

INSERT INTO users(username, password, account_non_expired, account_non_locked, credentials_non_expired, enabled, first_name, last_name, email_address, birthdate)
VALUES ('Developer', '$2a$12$2yOChyhSuJm/naTBUjGZb.6d6mu1NsXS8XWRFousQfRTwzy0ZQtWW'
       , true, true, true, true, 'Willy', 'De Keyser', 'wdkeyser@gmail.com', DATE('1990-01-01'));
INSERT INTO users(username, password, account_non_expired, account_non_locked, credentials_non_expired, enabled, first_name, last_name, email_address, birthdate)
VALUES ('Admin', '$2a$12$2yOChyhSuJm/naTBUjGZb.6d6mu1NsXS8XWRFousQfRTwzy0ZQtWW'
       , true, true, true, true, 'Walter', 'De Keyser', 'wdkeyser@gmail.com', DATE('1990-01-01'));
INSERT INTO users(username, password, account_non_expired, account_non_locked, credentials_non_expired, enabled, first_name, last_name, email_address, birthdate)
VALUES ('User', '$2a$12$2yOChyhSuJm/naTBUjGZb.6d6mu1NsXS8XWRFousQfRTwzy0ZQtWW',
        true, true, true, true, 'Ken', 'De Keyser', 'wdkeyser@gmail.com', DATE('1990-01-01'));

INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 1, 1 );
INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 1, 2 );
INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 1, 3 );
INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 2, 1 );
INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 2, 2 );
INSERT INTO  users_authorities(users_id, authorities_id) VALUES ( 3, 1 );

패스워드는 "password"이며 BCrypt로 암호화 되어 있다.

이제 User를 상속한 MyUser를 생성한다.

@Getter
@Setter
public class MyUser extends User {

    @Serial
    private static final long serialVersionUID = 1L;

    public MyUser(String username, String password, boolean enabled, boolean accountNonExpired,
                  boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities,
                  String firstName, String lastName, String emailAddress, LocalDate birthdate
    ) {
        super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
        this.firstName = firstName;
        this.lastName = lastName;
        this.fullName = "%s %s".formatted(firstName, lastName);
        this.emailAddress = emailAddress;
        this.birthdate = birthdate;
    }
    private String firstName;
    private String lastName;
    private String fullName;
    private String emailAddress;
    private LocalDate birthdate;
}

그리고 MyUserDetailsService의 loadUserByUseraneme에서 반환하는 UserDetails를 MyUser로 매핑하여 반환해준다.

    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    ...
    Optional<Users> userByUsername = usersRepository.findByUsername(username);
    ...
    return new MyUser(user.getUsername(), user.getPassword(), true, true, true, true, grantedAuthorities,
                user.getFirstName(), user.getLastName(), user.getEmailAddress(), user.getBirthdate());
    ...
    }

이제 HelloController에서 인증된 사용자 정보를 MyUser로 타입 변환후 사용할 수 있다.

    @GetMapping("/admin")
    public String admin(Authentication authentication) {
        MyUser principal = (MyUser) authentication.getPrincipal();
        return String.format("<h1>Welcome %s Admin!</h1> <h2> %s </h2> <ol><li>%s</li><li>%s</li></ol>",
                authentication.getName(), authentication.getAuthorities(), principal.getFullName(), principal.getBirthdate());
    }

여기까지 UserDetailsService와 UserDetails를 Custom하여 사용하는 방법을 알아보았다.

반응형

'SpringFramework > Spring Security' 카테고리의 다른 글

Spring Security Architecture  (0) 2023.07.18
Multiple SecurityChain  (0) 2023.07.09
Spring Security OAuth(Google)  (0) 2023.07.09
Spring Security Embedded H2 Database 사용 방법  (0) 2023.07.08
Security Configuration  (0) 2023.07.07