[Spring Security] User 정의와 관리

10000JI·2024년 1월 25일
0

Spring Boot

목록 보기
5/15

메모리에 다수의 User 생성하기 version1

지금까지 application.properties를 이용하여 한 명의 유저를 생성하여 사용하였다.

그러나 이 방법은 하위 환경에서만 사용 가능하며, 실제 운영 환경에서는 부적합하다.

따라서 이번에는 여러 유저를 생성하고, 그 정보를 메모리에 저장하는 방법과 데이터베이스에 저장하는 방법에 대해 알아보자.

InMemoryUserDetailsManager 활용

먼저, Spring Boot 웹 애플리케이션의 메모리에 다수의 유저를 생성하기 위해 ProjectSecurityConfig 클래스에 userDetailService()라는 메소드명을 생성하여 InMemoryUserDetailsManager를 사용한다.

ProjectSecurityConfig

package com.eazybytes.springsecsection2.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class ProjectSecurityConfig {

    //람다(Lambda) DSL 스타일 사용을 권장
    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        /**
         * 사용자 정의 보안 설정
         */
        http.authorizeHttpRequests((requests) -> requests
                        .requestMatchers("/myAccount", "/myBalance", "/myLoans", "/myCards").authenticated() //보호되길 원함
                        .requestMatchers("/notices", "/contact").permitAll()) //누구든 접근 가능
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        UserDetails admin = User.withDefaultPasswordEncoder()
                .username("admin")
                .password("12345")
                .authorities("admin")
                .build();
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("user")
                .password("12345")
                .authorities("read")
                .build();
        return new InMemoryUserDetailsManager(admin, user);
    }
}
  1. admin 사용자 생성:
  • User.withDefaultPasswordEncoder(): 텍스트 비밀번호를 사용하는 사용자를 생성하기 위한 메서드이다. (프로덕션에서는 사용하지 않는 것이 좋다.)

  • username("admin"): 사용자의 아이디를 "admin"으로 설정한다.

  • password("12345"): 사용자의 비밀번호를 "12345"로 설정합니다.

  • authorities("admin"): 사용자에게 부여된 권한을 "admin"으로 설정한다.

  • build(): 설정한 정보로 사용자 객체를 생성한다.

  1. user 사용자 생성:
  • User.withDefaultPasswordEncoder(): 마찬가지로 텍스트 비밀번호를 사용하는 사용자를 생성한다.

  • username("user"): 사용자의 아이디를 "user"로 설정한다.

  • password("12345"): 사용자의 비밀번호를 "12345"로 설정한다.

  • authorities("read"): 사용자에게 부여된 권한을 "read"로 설정한다.

  • build(): 설정한 정보로 사용자 객체를 생성한다.

<User는 UserDetails를 구현하고 있다.>

  1. InMemoryUserDetailsManager에 사용자 추가:
  • return new InMemoryUserDetailsManager(admin, user);: 앞에서 생성한 두 명의 사용자를 InMemoryUserDetailsManager에 추가하여 반환한다.

<InMemoryUserDetailsManager의 생성자는 원하는 수의 유저를 전달받아 createUser를 호출>

이렇게 InMemoryUserDetailsManager를 사용하면 Spring Security에서 제공하는 인메모리 방식으로 사용자를 생성하고, 이를 통해 로그인 인증 등의 보안 기능을 쉽게 구현할 수 있다.

application.properties 비워두기

# application.properties

# 코드 삭제

메모리에 다수의 User 생성하기 version2

메모리에 사용자를 만드는 두 가지 접근 방법에 대해 다루고 있다.

특히, 두 번째 접근 방법에 초점을 맞추어 NoOpPasswordEncoder를 사용하여 비밀번호를 일반 텍스트로 다루는 방법을 알아보자.

접근 방법 version2 설명

  • UserDetails를 생성할 때 User 클래스를 활용한 방법.

  • 여기선 NoOpPasswordEncoder를 활용하며, UserDetails 생성 시 PasswordEncoder와 관련된 메소드를 사용하지 않음.

  • 대신 별도의 PasswordEncoder 메소드를 정의하고 이를 Bean으로 반환함.

NoOpPasswordEncoder 사용 이유

  • NoOpPasswordEncoder는 Spring Security 프레임워크 내에서 사용 가능한 가장 간단한 PasswordEncoder로, 비밀번호를 일반 텍스트로 저장함.

  • 보안상 권장되지 않는데도 불구하고, 이 방법은 웹 애플리케이션 메모리 내에 사용자 자격 증명을 저장하는 간단한 접근 방법을 제공함.

구현 내용

User 클래스 수정

  • withDefaultPasswordEncoder 메소드를 삭제하고, 대신 NoOpPasswordEncoder를 사용하는 방법으로 코드 수정.
package com.eazybytes.springsecsection2.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.core.userdetails.User;
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 org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

import javax.sql.DataSource;

@Configuration
public class ProjectSecurityConfig {

    //람다(Lambda) DSL 스타일 사용을 권장
    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        /**
         * 사용자 정의 보안 설정
         */
        http.authorizeHttpRequests((requests) -> requests
                        .requestMatchers("/myAccount", "/myBalance", "/myLoans", "/myCards").authenticated() //보호되길 원함
                        .requestMatchers("/notices", "/contact").permitAll()) //누구든 접근 가능
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
    
        /**
         * 사용자 세부 정보를 만드는 동안
         * NoOpPasswordEncoder Bean을 사용 - version2
         */
        UserDetails admin = User.withUsername("admin") //UserDetails 생성 시 PasswordEncoder와 관련된 메서드 대신 withUserName("admin")을 사용
                .password("12345")
                .authorities("admin")
                .build();
        UserDetails user = User.withUsername("user")
                .password("12345")
                .authorities("read")
                .build();
        return new InMemoryUserDetailsManager(admin, user);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance(); //비밀번호를 일반 텍스트로 저장
    }

}

주의사항

  • 프로덕션 환경에서는 보안상의 이유로 권장되지 않음.

  • 사용자 자격 증명을 코드 및 웹 애플리케이션 메모리에 저장하므로, 보안에 취약할 수 있음.

User 관리 인터페이스와 클래스들에 대한 이해

UserDetailsManager와 UserDetailsService의 역할에 대한 의문이 남아 있을 수 있다.

이 두 구성 요소가 어떤 역할을 하는지에 대해 자세히 이야기해보자.

UserDetailsManager와 UserDetailsService

Spring Security에서는 사용자 관리를 위해 두 가지 핵심 인터페이스를 제공한다.

< Spring Security 내부 흐름도 >

UserDetailsService

UserDetailsService는 loadUserByUsername이라는 메소드를 포함한 추상 메소드를 정의한 인터페이스이다.

  • loadUserByUsername: 사용자의 세부 정보를 로드하는 메소드.

주로 사용자의 세부 정보를 저장 시스템에서 로드하는 데에 활용된다.

사용자의 인증 작업에서는 먼저 유저 이름(username)을 사용하여 사용자의 세부 정보를 로드한다.

-> 왜 username과 password 둘 다 로드하지 않는가?

  • 비밀번호를 불필요하게 네트워크로 전송해서는 안 된다.
    user의 실제 DB에 비밀번호를 보내는 것은 권장하지 않는 방식이다.
    따라서 Spring Security는 먼저 username을 사용하여 User 세부 정보를 로드하는 것이다.

UserDetailsManager

UserDetailsManager는 사용자의 세부 정보를 관리하는 데에 도움을 주는 인터페이스이다.

사용자를 생성하거나 업데이트하거나 삭제하거나 비밀번호를 변경하는 등의 작업을 수행할 수 있다.

  • createUser(userDetails): 새로운 유저를 등록한다.
  • updateUser(userDetails): 유저의 프로필 세부 정보를 업데이트한다.
  • deleteUser(username): 유저를 삭제한다.
  • changePassword(username, newPassword): 유저의 비밀번호를 변경한다.
  • userExists(username): 주어진 유저 이름을 시스템 내에 유저가 존재하는지 확인한다.

UserDetailsManager는 UserDetailsService를 확장하면서 더 많은 유저 관리 기능을 제공한다.

UserDetailsManager의 일부 샘플 구현을 생성

구현 클래스 및 샘플 구현

구현한 일부 샘플 클래스들을 다음과 같다.

  • InMemoryUserDetailsManager: 메모리에 사용자 세부 정보를 저장 및 관리.

  • JdbcUserDetailsManager: 데이터베이스에서 사용자 세부 정보를 검색.

  • LdapUserDetailsManager: LDAP 서버를 통해 사용자 세부 정보를 관리.

DaoAuthenticationProvider

하지만 자체 인증 로직이 있거나 모든 것을 직접 작성하려는 경우엔 자체 AuthenticationProvider를 정의해야 한다.

현재는 DaoAuthenticationProvider라는 기본 AuthenticationProvider를 사용하여 인증 작업을 수행하고 있다.

이는 UserDetailsManager 중 하나인 InMemoryUserDetailsManager 등을 활용한다.

UserDetails 인터페이스와 User 클래스

UserDetails 인터페이스와 그 구현 클래스는 엔드 유저의 세부 정보를 나타낸다.

세부 정보에는 이름, 비밀번호, 권한 등이 저장될 수 있다.

UserDetails 인터페이스

메소드

  • getAuthorities: 권한 또는 역할 목록 반환.

  • getPassword, getUsername: 비밀번호와 유저 이름 반환.

  • isAccountNonExpired, isAccountNonLocked, isCredentialsNonExpired, isEnabled: 계정 유효성 여부 확인.

User 클래스

  • UserDetails 인터페이스의 구현 클래스로, 세부 정보 메소드를 오버라이드하여 구현.

  • 생성자를 통해 객체 생성 시, 계정 상태 및 세부 정보 전달 가능.

  • 기본 생성자: 불리언 값 전달 없이 유저 이름, 비밀번호, 권한 전달.

  • 불리언 값 전달 시: 계정 상태 설정 가능.

UserDetails의 활용

  • InMemoryUserDetailsManager, JdbcUserDetailsManager, UserDetailsService 등에서 사용.

  • 메소드 반환 유형이나 비즈니스 로직에서 활용.

구현의 중요한 특징

  • UserDetails와 User 클래스 내에는 setter 메소드가 없음.

  • 객체 생성 후 값 재지정 불가능. 보안 상의 이유로 의도적.

InMemoryUserDetailsManager

  • loadUserByUsername 메소드로 유저 정보를 맵에서 불러옴.

  • UserDetails 객체 생성하여 반환.

AuthenticationProvider

  • loadUserByUsername 메소드를 호출하여 인증 객체 생성.

  • DaoAuthenticationProvider에서는 성공 시 createSuccessAuthentication 메소드 호출.

Authentication 인터페이스

  • Principal 인터페이스를 확장하며, getName 메소드만 가짐.

  • Authentication은 isAuthenticated, setAuthenticated 등의 메소드를 제공.

    • isAuthenticated(): 프레임워크가 주어진 유저가 성공적으로 인증되었는지 여부 이해

    • setAuthenticated() : 프레임워크가 isAuthenticated()의 값을 설정하는 데 사용

      성공적인 인증이 완료되면 이 메서드를 사용하여 true 값을 설정하고, 그렇지 않으면 fasle 값 설정

결론

  • UserDetails와 Authentication 간의 관계 명확하게 이해 필요.

1. UserDetails

  • DB or 스토리지 등 저장 시스템에서 로드할 때

2. Autentication

  • User 세부 정보가 데이터베이스에서 로드되면 세부 정보는 확실히 AutenticationProviers에 전달

  • AutenticationProviers 내부에서 인증이 성공하면 AutenticationProviers는 모든 정보와 함께 성공적인 인증 세부 정보를 인증 객체 데이터 유형으로 변환하는 것이 책임

인증 세부 정보와 유저 세부 정보를 나타내는 두 가지 방식에 주목.

UserDetailsManager 구현 클래스 분석

UserDetailsManager 구현 클래스에 대한 내용을 자세히 살펴보고자.
주로 사용되는 세 가지 구현 클래스에 대한 목적과 구조를 이해해보자.

pom.xml에 dependency 추가

	<dependency>
		<groupId>org.springframework.ldap</groupId>
		<artifactId>spring-ldap-core</artifactId>
	</dependency>
    
	<dependency>
		<groupId>org.springframework.security</groupId>
		<artifactId>spring-security-ldap</artifactId>
	</dependency>

1. InMemoryUserDetailsManager

  • createUser()
    • 새로운 유저를 등록하는 메소드
    • 전달된 유저 정보를 내부 맵에 저장

<자체 SecurityConfig 파일에서 InmemoryUserDetailManager로 user객체를 매개변수로 넣어 생성자를 리턴 받으면 createUser() 실행>

  • deleteUser()
    • 특정 유저를 삭제하는 메소드
    • 지정된 키에 해당하는 정보를 내부 맵에서 삭제
  • updateUser()
    • 유저 정보를 업데이트하는 메소드
    • 내부 맵에서 해당 유저 정보를 갱신
  • userExists()
    • 특정 유저가 존재하는지 확인하는 메소드
    • 내부 맵에서 해당 유저의 존재 여부를 반환
  • loadUserByUsername()
    • 저장 시스템에서 유저 정보를 불러오는 메소드
    • 내부 맵에서 유저 이름을 기반으로 정보를 반환

2. JdbcUserDetailsManager

  • createUserSql()
    • 새로운 유저를 등록하는 SQL 쿼리
    • users 테이블에 유저 정보를 삽입
  • deleteUser()
    • 특정 유저를 삭제하는 SQL 쿼리
    • users 테이블에서 해당 유저 정보를 삭제
  • updateUser()
    • 유저 정보를 업데이트하는 SQL 쿼리
    • users 테이블에서 해당 유저 정보를 갱신
  • userExists()
    • 특정 유저가 존재하는지 확인하는 SQL 쿼리
    • users 테이블에서 해당 유저의 존재 여부를 반환
  • loadUserByUsername()
    • 저장 시스템에서 유저 정보를 불러오는 메소드
    • users 테이블에서 유저 이름을 기반으로 정보를 반환

3. LdapUserDetailsManager

흔하게 사용되는 구현 클래스는 아니다.

저장 시스템으로 LDAP 서버가 있지 않은 이상 이 UserDetailsManager를 사용하지 못할 수도 있기 때문이다.

  • loadUserByUsername()
    • 저장 시스템에서 유저 정보를 불러오는 메소드
    • users 테이블에서 유저 이름을 기반으로 정보를 반환

데이터베이스 설정과 연결

데이터베이스 스키마 생성

  • 스크립트 사용
create database eazybank
  • 실행 후 해당 데이터베이스 사용 설정
use eazybank

테이블 생성

  • MySQL 표준에 따라 스크립트 수정
create table 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 authorities(
	id int not null auto_increment,
	username varchar(45) not null,
	authority varchar(45) not null,
	primary key (id));

레코드 추가

  • INSERT 명령어 사용
insert ignore into users values (null,'happy','12345','1');
insert ignore into authorities values (null,'happy','write');

JdbcUserDetailManager를 사용한 인증

MariaDB를 구축하고, Spring Security에서 제시하는 표준에 따라 JdbcUserDetailsManager를 사용하여 인증을 수행해보자.

추가로 보안을 강화하기 위해 비밀번호를 해싱과 암호화된 형태로 저장할 것이다.

pom.xml에 dependency 추가

	<dependencies>
    	<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-jdbc</artifactId>
		</dependency>
        
		<dependency>
			<groupId>org.mariadb.jdbc</groupId>
			<artifactId>mariadb-java-client</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
	<dependency>

데이터베이스 연결 정보 설정

  • application.properties 파일을 열고 MySQL 데이터베이스의 연결 정보를 입력
# mariadb 데이터베이스 연결 정보
spring.datasource.url=jdbc:mariadb://localhost:3306/eazybank
org.mariadb.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=root1234

# 콘솔에 SQL 출력 설정, 배포 이후엔 지우기
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Security Configuration 수정

ProjectSecurityConfig 클래스를 열고 InMemoryUserDetailsManager 관련 코드를 주석 처리.

그리고 JdbcUserDetailsManager 타입의 Bean을 생성한다.

userDetailsService() 메소드의 매개변수는 DataSource가 들어왔다. 이러면 MariaDB 관련 dependency를 클래스 경로에 추가하고 데이터베이스의 property를 application.properties에서 정의할 때마다 Spring Boot는 데이터 소스 객체를 자동적으로 웹 애플리케이션 내부에 생성한다.

즉, DataSource가 JdbcUserDetailsManager에게 넘어갈 때마다 말이다.

package com.eazybytes.springsecsection2.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.core.userdetails.User;
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 org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

import javax.sql.DataSource;

@Configuration
public class ProjectSecurityConfig {

    //람다(Lambda) DSL 스타일 사용을 권장
    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        /**
         * 사용자 정의 보안 설정
         */
        http.authorizeHttpRequests((requests) -> requests
                        .requestMatchers("/myAccount", "/myBalance", "/myLoans", "/myCards").authenticated() //보호되길 원함
                        .requestMatchers("/notices", "/contact").permitAll()) //누구든 접근 가능
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService(DataSource dataSource) {
        return new JdbcUserDetailsManager(dataSource);
    }

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

}

결과

DB에 저장된 User 정보를 입력하였더니 인증되어 보호된 url에 접근이 가능해졌다.


인증을 위한 사용자 정의 테이블 생성

현재까지는 JdbcUserDetailsManager를 사용하여 인증을 수행하고 있다.

현실적인 웹 애플리케이션에서는 자체적인 데이터베이스 스키마와 명명 규칙을 가지고 있을 것이며, 이를 따르기 위해 더 많은 작업이 필요하다.

Spring Boot에서 JdbcUserDetailsManager가 UserDetailsManager (혹은 UserDetialsService)를 구현해 클래스를 만든 것처럼 우리는 우리만의 UserDetailsManager (혹은 UserDetialsService)를 구현한 사용자 정의 클래스를 만들면 된다.

다양한 데이터베이스 스키마 요구사항

  1. 다양한 인증 수단: 클라이언트는 이메일을 이용한 인증을 원할 수 있으며, username 대신 email을 사용하고 싶을 수 있다.

  2. 명명 규칙: 기업은 자체적인 명명 규칙을 가질 수 있어, Spring Security의 스키마 구조를 그대로 사용하기 어려울 수 있다.

  3. 유연한 표 구조: 기존의 authoritiesusers 테이블은 Spring Security의 스키마를 따르지만, 이를 사용하지 않고 자체적으로 만든 테이블을 가지고 싶을 수 있다.

사용자 정의 데이터베이스 스키마 생성

현재까지의 스크립트에서는 customer라는 새로운 테이블을 생성하고, 여기에 이메일, 비밀번호(pwd), 역할(role) 등의 열을 포함했다.

이것은 Spring Security의 스키마와는 다르지만, 우리가 원하는 데이터베이스 구조를 만들어 놓아보자.

CREATE TABLE customer (
    id INT NOT NULL AUTO_INCREMENT,
    email VARCHAR(255) NOT NULL,
    pwd VARCHAR(255) NOT NULL,
    role VARCHAR(255) NOT NULL,
    primary key (id)
);

INSERT INTO customer (email, pwd, role) VALUES ('johndoe@example.com', '54321', 'admin');

변경이 필요한 부분

기존에는 JdbcUserDetailsManager를 사용했지만, 이제는 이를 사용할 수 없다.

대신에 우리만의 로직을 작성하여 인증을 수행해야 한다.

또한, 이제는 email을 사용자 식별자로 사용하고, pwdrole 등을 고려하여 로그인을 진행해야 한다.


새로운 테이블을 위한 JPA Entity와 리포지토리 클래스 생성

위에서 데이터베이스 테이블을 만들었다.

이제는 해당 테이블을 인증에 활용할 수 있도록 코드를 수정해야 한다.

첫 번째 단계로는 Spring Data JPA 프레임워크를 사용하여 테이블 데이터를 읽어오기 위한 엔터티 클래스를 생성해보자.

엔터티 클래스 생성

com.eazybytes 패키지 내에 model이라는 새로운 패키지를 생성하고, 그 안에 Customer라는 엔터티 클래스를 작성하자.

package com.eazybytes.model;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private int id;
    private String email;
    private String pwd;
    private String role;

    // Getter and Setter methods
}

위 코드에서 @Entity 어노테이션은 이 클래스가 Spring Data JPA 프레임워크에서 사용되는 엔터티임을 나타낸다.

또한, @Id@GeneratedValue 어노테이션은 해당 필드가 테이블의 기본 키 역할을 한다는 것을 나타낸다.

JPA Repository 인터페이스 생성

com.eazybytes.repository 패키지를 생성하고, 그 안에 CustomerRepository라는 인터페이스를 작성한다.

package com.eazybytes.springsecsection2.repository;

import com.eazybytes.springsecsection2.model.Customer;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface CustomerRepository extends CrudRepository<Customer, Long> {
    List<Customer> findByEmail(String email);
}

위 코드에서 CustomerRepositorySpring Data JPACrudRepository를 상속하고 있다.

이 인터페이스를 통해 자동으로 CRUD 연산에 필요한 메소드들이 생성된다.

예를 들어 findByEmail 메소드는 이메일을 기반으로 데이터를 조회하기 위한 메소드로, 해당 이름 규칙에 따라 Spring Data JPA가 구현을 자동으로 생성한다.

EazyBankBackendApplication에 주석(어노테이션) 추가

만약 EazyBankBackendApplication 클래스가 메인 패키지 외부에 위치한다면, 다음 주석(어노테이션)들을 해당 클래스에 추가해야 한다.

@SpringBootApplication
//@EnableJpaRepositories("com.eazybytes.repository")
//@EntityScan("com.eazybytes.model")
//@EnableWebSecurity
public class EazyBankBackendApplication {

    public static void main(String[] args) {
        SpringApplication.run(EazyBankBackendApplication.class, args);
    }

}

@EnableJpaRepositories("com.eazybytes.repository"), @EntityScan("com.eazybytes.model"),@EnableWebSecurity들은 각각 리포지터리, 엔티티 그리고 웹 시큐리티를 활성화하기 위한 것이다.

특히 @EnableJpaRepositories("com.eazybytes.repository"), @EntityScan("com.eazybytes.model")은 리포지터리 혹은 엔티티가 존재하는 패키지명을 입력해줘야 한다.

@EnableWebSecurity은 Spring Boot에선 자동으로 활성화 해주기 때문에 주석처리 해도 되지만, Spring 환경에서는 적어줘야 한다.

이러한 설정들은 현재 나의 프로젝트 구조처럼 엔티티와 리포지터리 등이 메인 패키지에 생성하였으므로 @EnableJpaRepositories("com.eazybytes.repository"), @EntityScan("com.eazybytes.model")을 Spring Boot가 자동으로 처리하여 주석처리해도 무관하다.

하지만 특정 패키지 구조를 따르지 않는 경우 수동으로 추가해야 한다.

이로써 엔터티와 리포지터리에 대한 변경이 완료되었다.


맞춤형 UserDetailService 구현

Spring Security 프레임워크를 사용하여 사용자 인증을 커스터마이징하는 방법에 대해 다뤄보자.

주요 내용은 UserDetailsService를 구현하여 사용자 정보를 데이터베이스에서 가져오고, 해당 정보를 Spring Security 프레임워크에 제공하여 사용자 인증을 가능케 하는 것이다.

EazyBankUserDetails 클래스 생성

  1. conflict 패키지 내부에 EazyBankUserDetails 클래스를 생성한다.

  2. 이 클래스는 UserDetailsService를 구현하여야 합니다.

  3. loadUserByUsername() 메소드를 오버라이드하고 내부에 사용자 정보를 맞춤 표로부터 가져와서 UserDetails 객체로 변환하는 비지니스 로직을 작성한다.

4.@RequiredArgsConstructor 어노테이션을 사용하여 이 리포지터리를 자동으로 주입받을 수 있도록 설정한다.

loadUserByUsername() 메소드 작성

  1. loadUserByUsername() 메소드 내에서 필요한 클래스들을 사용하기 위해 다양한 import 문을 추가한다.

  2. 사용자 정보를 가져오기 위해 데이터베이스에서의 조회를 위해 Spring Data JPA의 CustomerRepository를 활용한다.

  3. 입력된 이메일 주소로 데이터베이스에서 해당하는 고객을 조회한다.

  4. 조회된 고객이 없을 경우 실패 메시지를 반환하고, 있을 경우 사용자 정보를 생성하여 반환한다.

User 클래스의 권한 정보를 GrantedAuthority 형태로 받아들인다.

SimpleGrantedAuthority는 GrantedAuthority 인터페이스를 구현한다.

Bean으로 등록

  1. EazyBankUserDetails 클래스에 @Service 어노테이션을 추가하여 Spring Security가 해당 클래스를 Bean으로 이해할 수 있도록 한다.

UserDetailsService를 구현한 EazyBankUserDetails 이므로 Service임을 알려주기 위해 어노테이션 @Service를 붙이는 것

완성된 EazyBankUserDetails

package com.eazybytes.springsecsection2.config;

import com.eazybytes.springsecsection2.model.Customer;
import com.eazybytes.springsecsection2.repository.CustomerRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import java.util.ArrayList;
import java.util.List;

@Serivce
@RequiredArgsConstructor
public class EazyBankUserDetails implements UserDetailsService {

    private final CustomerRepository customerRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String userName, password;
        List<GrantedAuthority> authorities;
        List<Customer> customer = customerRepository.findByEmail(username);
        
        //찾아온 customer이 한명도 없다면?
        if (customer.size() == 0) {
            throw new UsernameNotFoundException("User details not found for the user : " + username);
        } else{
            //db에 저장된 email과 pwd를 반환
            userName = customer.get(0).getEmail();
            password = customer.get(0).getPwd();
            authorities = new ArrayList<>();
            
            //User 클래스의 권한 정보는 GrantedAuthority의 형태로 받아들임
            //그에 따라 SimpleGrantedAuthority 클래스 생성
            //SimpleGrantedAuthority는 GrantedAuthority 인터페이스 구현
            authorities.add(new SimpleGrantedAuthority(customer.get(0).getRole()));
        }
        
        //userName과 password는 User 클래스 생성자에 넘김
        //같은 값을 DaoAuthenticationProvider에게 전달하기 위해서
        //DaoAuthenticationProvider에서는 DB에서 보내는 PW과 엔드 유저로부터 받는 비밀번호를 비교
        return new User(userName,password,authorities);
    }
}

ProjectSecurityConfig 파일 내 JdbcUserDetailsManager 주석 처리

  1. JdbcUserDetailsManager에 관한 코드를 주석 처리하여 해당 클래스를 UserDetailsService의 구현 클래스로 사용하지 않도록 설정한다.

EazyBankUserDetails는 이미 Bean으로 등록했기 때문에 수동 Bean 처리가 필요X

ProjectSecurityConfig

package com.eazybytes.springsecsection2.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

import javax.sql.DataSource;

@Configuration
public class ProjectSecurityConfig {

    //람다(Lambda) DSL 스타일 사용을 권장
    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        /**
         * 사용자 정의 보안 설정
         */
        http.authorizeHttpRequests((requests) -> requests
                        .requestMatchers("/myAccount", "/myBalance", "/myLoans", "/myCards").authenticated() //보호되길 원함
                        .requestMatchers("/notices", "/contact").permitAll()) //누구든 접근 가능
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }

	//JdbcUserDetailsManager 주석처리
//    @Bean
//    public UserDetailsService userDetailsService(DataSource dataSource) {
//        return new JdbcUserDetailsManager(dataSource);
//    }

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

}

결과 확인

  1. 웹 애플리케이션을 실행하고 테스트를 위해 API를 호출한다.

  2. 올바르지 않은 자격 증명을 입력한 경우 예외가 발생하고, 올바른 자격 증명을 입력한 경우 사용자 인증이 성공적으로 이루어진다.

마무리

  1. Spring Security를 통해 웹 애플리케이션을 보안화하고, 사용자 인증에 맞춤 로직을 적용하는 방법을 학습했다.

  2. 자체적으로 구현한 UserDetailsService 구현 클래스를 Bean으로 등록하여 Spring Security가 해당 클래스를 이해하도록 설정한다.

  3. 코드의 변경으로 충돌이 발생할 경우, 관련 클래스의 주석 처리 등을 통해 해결하는 방법도 소개되었다.

  4. 다음 강의에서는 더 많은 Spring Security의 기능과 확장에 대해 다룰 예정이다.


새로운 유저 등록을 허용하는 새 REST API 구축

현재는 사용자 정보를 데이터베이스에 수동으로 입력하는 방법을 사용하고 있다.

하지만 이는 현실적이지 않으며, 엔드 유저들이 직접 자격 증명을 입력하여 등록할 수 있어야 한다.

이를 위해 사용자가 직접 정보를 입력하여 데이터베이스에 등록하고, 이 정보를 사용하여 Spring Boot 웹 애플리케이션이나 REST 서비스에 접근할 수 있도록 새로운 비즈니스 로직을 작성한다.

해결 방법

두 가지 방법으로 문제를 해결할 수 있다.

1. UserDetailsManager 인터페이스 구현

UserDetailsManager 인터페이스를 구현하여 createUser, updateUser, deleteUser 등의 메소드를 오버라이드하여 비즈니스 로직을 작성한다.

그러나 이 방법은 Spring Security를 엔드 유저 관리와 연관시키기 때문에 사용하고 싶지 않다.

2. REST 서비스를 이용한 등록

인터페이스를 구현하지 않고, 간단한 REST 서비스를 통해 엔드 유저가 직접 등록할 수 있는 방법을 선택한다.

새로운 LoginController 클래스를 생성하여, @RestController 어노테이션을 사용하여 새로운 REST 서비스를 만든다.

/register 경로의 POST 메소드를 생성하고, 해당 메소드에서는 CustomerRepository Bean을 주입받아 사용한다.

CustomerRepository를 통해 새로운 고객 정보를 데이터베이스에 저장하고, 성공 여부에 따라 응답을 반환한다.

등록 API에 보안을 적용하기 위해 Spring Security 설정 클래스에 /register 경로를 permitAll로 추가한다.

코드 구현

LoginController

package com.eazybytes.springsecsection2.controller;

import com.eazybytes.springsecsection2.model.Customer;
import com.eazybytes.springsecsection2.repository.CustomerRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class LoginController {
    private final CustomerRepository customerRepository;
    @PostMapping("/register")
    public ResponseEntity<String> registerUser(@RequestBody Customer customer) {
        Customer savedCustomer = null;
        ResponseEntity response = null;
        try {
            //고객 정보 저장
            savedCustomer = customerRepository.save(customer);

            //CrudRepository에선 고객 정보 저장 후, 고객 정보를 그대로 리턴
            //리턴 받은 고객정보의 기본키(=id)가 0보다 크다면 정상저장된 것
            if (savedCustomer.getId() > 0) {
                //상태코드 201번
                response = ResponseEntity
                        .status(HttpStatus.CREATED)
                        .body("Given user details are successfully registered");
            }
        } catch (Exception ex) {
            //상태코드 500번
            response = ResponseEntity
                    .status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("An exception occured due to " + ex.getMessage());
        }
        return response;
    }
}

ProjectSecurityConfig

package com.eazybytes.springsecsection2.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import javax.sql.DataSource;

@Configuration
public class ProjectSecurityConfig {

    //람다(Lambda) DSL 스타일 사용을 권장
    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        /**
         * 사용자 정의 보안 설정
         */
        http.authorizeHttpRequests((requests) -> requests
                        .requestMatchers("/myAccount", "/myBalance", "/myLoans", "/myCards").authenticated() //보호되길 원함
                        .requestMatchers("/notices", "/contact","/register").permitAll()) //누구든 접근 가능
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }


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

}

문제사항

</register 경로를 permitAll로 추가하여도 403 에러가 발생>

이는 CSRF 보안 때문이다.

Spring Security 프레임워크의 기본 설정은 어떤 POST 요청이든 간에 데이터베이스나 백엔드 내부 데이터를 수정하고자 하면 막도록 되어있다.

따라서 /register로 접근할 때 CSRF 공격을 막기 위해 모두 막아야 한다.

일단 CSRF 보안을 해제하고 추후 CSRF에 대해 정리할 때 자세히 다루겠다

ProjectSecurityConfig

package com.eazybytes.springsecsection2.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import javax.sql.DataSource;

@Configuration
public class ProjectSecurityConfig {

    //람다(Lambda) DSL 스타일 사용을 권장
    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        /**
         * 사용자 정의 보안 설정, csrf 보안 해제
         */
        http.csrf((csrf) -> csrf.disable())
                .authorizeHttpRequests((requests)->requests
                        .requestMatchers("/myAccount","/myBalance","/myLoans","/myCards").authenticated()
                        .requestMatchers("/notices","/contact","/register").permitAll())
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }

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

}

실행 및 테스트

  1. 코드를 빌드하여 실행한다.

  2. Postman 또는 유사한 도구를 사용하여 /register 경로에 POST 요청을 보낸다.

  1. 성공적인 경우 201 Created 응답을 확인하고, 데이터베이스에 새로운 기록이 추가되었는지 확인한다.
  1. 등록된 사용자로 Spring Security를 통해 로그인하여 보안된 리소스에 접근이 가능한지 확인한다.

참고

이 코드는 Spring Security와 데이터베이스를 이용한 간단한 사용자 등록 및 로그인 구현을 보여준다.

프로덕션 환경에서는 보다 강력한 보안 및 예외 처리가 필요하므로 실제 프로젝트에서는 주의하여 구현해야 한다.

이 정리된 내용을 참고하여 코드를 구현하고, 필요에 따라 보안 설정 및 예외 처리를 보완하기로 하자.


profile
Velog에 기록 중

0개의 댓글