이번 스프링 시큐리티는 Spring Data JPA를 통한 로그인 입니다

Spring Boot 3.0.2 버전 , Spring Security 6 사용하여 기존 5.7 이하에서 사용하던,

Deprecated 된 부분들(websecurityconfigureradapter , authorizeRequests)를 바꾸어 작성 해보았습니다.

( 급하신 분들은 MemberSecurityConfig.java 파일만 참고하시면 될 것 같습니다 )

 

 

깃허브 레포지토리에서 해당 소스나 패키지 구조 참고 가능합니다

( ps. 2023.02.21 14:03 로그인 기억하기 추가 깃허브 참조 )

 

https://github.com/wjsskagur/spring_security1

 

GitHub - wjsskagur/spring_security1

Contribute to wjsskagur/spring_security1 development by creating an account on GitHub.

github.com

 

* Spring Boot 3 버전부터 java 17 아래 버전으론 컴파일이 되지 않습니다 *

데이터베이스는 h2 기반으로 작성하였습니다.

 

Gradle 입니다

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.2'
    id 'io.spring.dependency-management' version '1.1.0'
}

group = 'security1'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

 

 

 

application.properties 입니다

# h2 DataBase 접속정보 ( h2 DataBase 설치 필요 )
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:~/Documents/h2/jun
spring.datasource.username=jun
spring.datasource.password=qwer1234

# jpa 기본 설정
# 영속성 컨텍스트 유지
spring.jpa.open-in-view=true
# sql 보기
spring.jpa.show-sql=true
# 쿼리 가독성
spring.jpa.properties.hibernate.format_sql=true
# 테이블 자동생성 끄기
spring.jpa.hibernate.ddl-auto=none

 

 

 

 

 

이후 전체적인 프로젝트 구조 및 파일 입니다.

아래 파일들은 코드와 같이 작업 할 파일들입니다.

 

 

자세한 구조는 GitHub 참조 바랍니다

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

html 3개의 소스입니다

먼저 로그인 페이지 templates/login/login.html

로그인 기억하기 사용을 위한 remember-me 을 사용하였습니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
    <h1>로그인 페이지</h1>
    <form action="/member/login/login" method="post">
        <div>
            <label for="username">아이디</label>
            <input type="text" name="username" id="username">
        </div>
        <div>
            <label for="password">비밀번호</label>
            <input type="password" name="password" id="password">
        </div>
        <div>
            <input type="checkbox" name="remember-me" id="rememberMe">
            <label for="rememberMe">자동 로그인</label>
        </div>
        <p style="display: none" class="error_txt" id="error_text"
           th:if="${#strings.length(session.loginErrorMessage)>0}"
           th:text="${session.loginErrorMessage}">로그인하시려면 아이디와 비밀번호를 입력해주세요</p>
        <button type="submit">로그인</button>
    </form>
</body>
<script>
    const error = document.getElementById('error_text');
    if(error !== undefined) alert(error.innerText);
</script>
</html>

 

로그인 완료시 이동할 페이지 templates/main/main.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>로그인성공</title>
</head>
<body>
    <h1>로그인 성공</h1>
    <p>로그인 성공</p>
    <a href="/member/text">회원 정보 확인하기</a>
    <a href="/member/login/logout">로그아웃</a>
</body>
</html>

 

로그인 된 회원정보를 출력할 페이지

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>로그인한 회원 정보</title>
</head>
<body>
    <p th:text="${'회원 로그인 아이디 : ' + member.loginId}"></p>
    <p th:text="${'회원 이름 : ' + member.name}"></p>
    <p th:text="${'회원 이메일 : ' + member.email}"></p>
</body>
</html>

 

해당 프로젝트에 사용할 DDL 입니다

DROP TABLE IF EXISTS MEMBER_TB;

CREATE TABLE MEMBER_TB
(
    MEMBER_ID       INT AUTO_INCREMENT NOT NULL,
    MEMBER_LOGIN_ID VARCHAR(20) NOT NULL,
    MEMBER_ROLE     VARCHAR(20) DEFAULT 'ROLE_MEMBER' NOT NULL,
    MEMBER_NAME     VARCHAR(20) NOT NULL,
    MEMBER_PASSWORD VARCHAR(400) NOT NULL,
    MEMBER_EMAIL    VARCHAR(100) NOT NULL,
    IS_USED         CHAR(1) DEFAULT 'Y' NOT NULL,
    IS_DEL          CHAR(1) DEFAULT 'N' NOT NULL,
    ISRT_DATE       DATETIME DEFAULT NOW() NOT NULL,
    UPDT_DATE       DATETIME ,
    CONSTRAINT MEMBER_PK PRIMARY KEY (MEMBER_ID)
);

-- 비밀번호 1111
INSERT INTO MEMBER_TB (MEMBER_LOGIN_ID, MEMBER_NAME, MEMBER_PASSWORD, MEMBER_EMAIL)
VALUES ('member1', '전남혁', '$2a$12$umem9giXuB0lDzAQ1ofzmeVqwHHFX76sbMObVEWpcIOPc6O.47NGa', 'all_step@naver.com');

-- 비밀번호 1234
INSERT INTO MEMBER_TB (MEMBER_LOGIN_ID, MEMBER_NAME, MEMBER_PASSWORD, MEMBER_EMAIL)
VALUES ('member2', '김철수', '$2a$12$R0ZgpAnBKh8CX0sATNRY8OyXPfke6GsXOxOA18gWyJ7RrnzOGnDOu', '');

 

이어서 엔티티 입니다

member/entity/Member

@Entity // 엔티티 생성시
@Getter
@Setter
@Builder // 추후 Builder 사용시
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Table(name = "MEMBER_TB")
public class Member {

    @Id // 엔티티 내부에서 아이디임을 선언
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 시퀀스 전략 선언
    @Column(name = "MEMBER_ID") // 아이디에 해당하는 컬럼명 선언
    private Long id;

    @Column(name = "MEMBER_LOGIN_ID")
    private String loginId;

    @Column(name = "MEMBER_ROLE")
    private String role;

    @Column(name = "MEMBER_NAME")
    private String name;

    @Column(name = "MEMBER_PASSWORD")
    private String password;

    @Column(name = "MEMBER_EMAIL")
    private String email;

    @Column(name = "IS_USED")
    private String isUsed;

    @Column(name = "IS_DEL")
    private String isDel;

    @Column(name = "ISRT_DATE")
    private LocalDateTime isrtDate;

    @Column(name = "UPDT_DATE")
    private LocalDateTime updtDate;
}

 

이어서 레포지토리 입니다

 member/repository/MemberRepository

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Member findByLoginId(String username);
}

 

Spring Security에서 사용자를 담아둘 클래스입니다

securty/auth/MemberPrincipalDetails

// Spring Security 에 있는 UserDetails 를 구현한 클래스,
// 이 클래스를 통해 Spring Security 에서 사용자의 정보를 담아둠
public class MemberPrincipalDetails implements UserDetails {


    // member 패키지에 선언해놓은 member 엔티티를 사용하기 위해 선언
    private final Member member;

    public MemberPrincipalDetails(Member member) {
        this.member = member;
    }

    // 생성자
    public Member getMember() {
        return member;
    }

    // member 계정의 권한을 담아두기위해
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(member.getRole()));
        return authorities;
    }

    // member 계정의 비밀번호를 담아두기 위해
    @Override
    public String getPassword() {
        return member.getPassword();
    }

    // member 계정의 아이디를 담아두기 위해
    @Override
    public String getUsername() {
        return member.getLoginId();
    }

    // 계정이 만료되지 않았는지를 담아두기 위해 (true: 만료안됨)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정이 잠겨있지 않았는지를 담아두기 위해 (true: 잠기지 않음)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // 계정의 비밀번호가 만료되지 않았는지를 담아두기 위해 (true: 만료안됨)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 계정이 활성화되어있는지를 담아두기 위해 (true: 활성화됨)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

 

스프링 시큐리티가 로그인 요청을 가로챌때 동작하는 서비스 입니다

security/auth/MemberPrincipalDetailService

// UserDetailsService 를 구현한 클래스
// 스프링 시큐리티가 로그인 요청을 가로챌 때, username, password 변수 2개를 가로채는데
// password 부분 처리는 알아서 함
@Service
public class MemberPrincipalDetailService implements UserDetailsService {

    // JPARepository 를 상속받은 인터페이스를 자동으로 DI (의존성 주입)
    @Autowired
    private MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 넘겨받은 id 로 DB 에서 회원 정보를 찾음
        Member member = memberRepository.findByLoginId(username);
        System.out.println("username : " + username);
        System.out.println("member : " + member);
        // 없을경우 에러 발생
        if(member == null)
            throw new UsernameNotFoundException(username + "을 찾을 수 없습니다.");

        if(!"Y".equals(member.getIsUsed()))
            throw new UsernameNotFoundException("사용할 수 없는 계정입니다.");

        if(!"N".equals(member.getIsDel()))
            throw new UsernameNotFoundException("삭제된 계정입니다.");

        // MemberPrincipalDetails 에 Member 객체를 넘겨줌
        return new MemberPrincipalDetails(member);
    }
}

 

 

 

로그인  정보를 가로챈 후 인증버로를 반환 하기위한 클래스입니다

security/provider/MemberAuthenticationProvider

@Component
// AuthenticationProvider 를 구현한 클래스
public class MemberAuthenticatorProvider implements AuthenticationProvider {

    @Autowired
    private MemberPrincipalDetailService memberPrincipalDetailService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName(); // 사용자가 입력한 id
        String password = (String) authentication.getCredentials(); // 사용자가 입력한 password

        // 생성해둔 MemberPrincipalDetailService 에서 loadUserByUsername 메소드를 호출하여 사용자 정보를 가져온다.
        MemberPrincipalDetails memberPrincipalDetails = (MemberPrincipalDetails) memberPrincipalDetailService.loadUserByUsername(username);

        // ====================================== 비밀번호 비교 ======================================
        // 사용자가 입력한 password 와 DB 에 저장된 password 를 비교한다.

        // db 에 저장된 password
        String dbPassword = memberPrincipalDetails.getPassword();
        // 암호화 방식 (BCryptPasswordEncoder) 를 사용하여 비밀번호를 비교한다.
        PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

        if(!passwordEncoder.matches(password, dbPassword)) {
            System.out.println("[사용자] 비밀번호가 일치하지 않습니다.");
            throw new BadCredentialsException("[사용자] 아이디 또는 비밀번호가 일치하지 않습니다.");
        }
        // ========================================================================================

        // 사용자가 입력한 id 와 password 가 일치하면 인증이 성공한 것이다.
        // 인증이 성공하면 MemberPrincipalDetails 객체를 반환한다.
        Member member = memberPrincipalDetails.getMember();
        if (member == null || "N".equals(member.getIsUsed())) {
            System.out.println("[사용자] 사용할 수 없는 계정입니다.");
            throw new BadCredentialsException("[사용자] 사용할 수 없는 계정입니다.");
        }

        // 인증이 성공하면 UsernamePasswordAuthenticationToken 객체를 반환한다.
        // 해당 객체는 Authentication 객체로 추후 인증이 끝나고 SecurityContextHolder.getContext() 에 저장된다.
        return new UsernamePasswordAuthenticationToken(memberPrincipalDetails, null, memberPrincipalDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

 

 

 

로그인 실패시 에러 메세지를 띄우기 위한 커스텀 핸들러 입니다

security/config/MemberAuthFailureHandler

public class MemberAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws ServletException, IOException {
        // 실패 메세지를 담기 위한 세션 선언
        HttpSession session = request.getSession();
        // 세션에 실패 메세지 담기
        session.setAttribute("loginErrorMessage", exception.getMessage());
        // 로그인 실패시 이동할 페이지
        setDefaultFailureUrl("/member/login/loginForm?error=true&t=h");
        // 로그인 실패시 이동할 페이지로 이동
        super.onAuthenticationFailure(request, response, exception);
    }
}

 

 

 

로그인 성공시 작동하는 커스텀 핸들러 입니다 ( 해당 프로젝트에선 사용하지 않습니다 )

security/config/MemberAuthSuccessHandler

public class MemberAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 로그인 성공시 이동할 페이지
        setDefaultTargetUrl("/member/main");
        // 로그인 성공시 이동할 페이지로 이동
        super.onAuthenticationSuccess(request, response, authentication);
    }

}

 

 

위에 작성한 파일들이 사용되는 파일입니다

security/config/MemberSecurityConfig

로그인 기억하기 사용을 위한 rememberMe 도 같이 사용하였습니

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class MemberSecurityConfig {
    /*
    * 중요
    * Spring Security 5.7.0 버전부터
    * WebSecurityConfigurerAdapter가 deprecated 되기 때문에
    * 이와 같은 방법으로 구현
    * */


    // 생성해둔 MemberAuthenticatorProvider를 주입받는다.
    // 해당 클래스로 MemberPrincipalDetailsService 내부 로직을 수행하며
    // 인증 처리도 같이 진행된다
    @Autowired
    MemberAuthenticatorProvider memberAuthenticatorProvider;

    // 로그인 기억하기 사용을 위해 MemberAuthenticatorProvider 내부
    // MemberPrincipalDetailsService 선언
    @Autowired
    MemberPrincipalDetailService memberPrincipalDetailService;


    // in memory 방식으로 인증 처리를 진행 하기 위해 기존엔 Override 하여 구현했지만
    // Spring Security 5.7.0 버전부터는 AuthenticationManagerBuilder를 직접 생성하여
    // AuthenticationManager를 생성해야 한다.
    @Autowired
    public void configure (AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(memberAuthenticatorProvider);
    }

    // 5.7.0 부터 Override 하지 않고
    // SecurityFilterChain을 직접 생성하여 구현
    // 그 외 authorizeRequests 가 deprecated 되었기 때문에
    // authorizeHttpRequests 로 변경
    @Bean
    public SecurityFilterChain memberSecurityFilterChain (HttpSecurity http) throws Exception {
        http.csrf().disable();

        http.authorizeHttpRequests(authorize -> {
            try {
                authorize
                        .requestMatchers("/css/**", "/images/**", "/js/**", "/member/login/**", "/member/attachment/**", "/member/files/**")
                        .permitAll() // 해당 경로는 인증 없이 접근 가능
                        .requestMatchers("/member/**") // 해당 경로는 인증이 필요
                        .hasRole("MEMBER") // ROLE 이 MEMBER 가 포함된 경우에만 인증 가능
                    .and()
                        .formLogin()
                        .loginPage("/member/login/loginForm") // 로그인 페이지 설정
                        .loginProcessingUrl("/member/login/login") // 로그인 처리 URL 설정
                        .defaultSuccessUrl("/member/main") // 로그인 성공 후 이동할 페이지
//                        .successHandler(new MemberAuthSuccessHandler()) // 로그인 성공 후 처리할 핸들러
                        .failureHandler(new MemberAuthFailureHandler()) // 로그인 실패 후 처리할 핸들러
                        .permitAll()
                    .and()
                        .logout()
                        .logoutUrl("/member/login/logout") // 로그아웃 처리 URL 설정
                        .logoutSuccessUrl("/member/login/loginForm?logout=1") // 로그아웃 성공 후 이동할 페이지
                        .deleteCookies("JSESSIONID"); // 로그아웃 후 쿠키 삭제
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        });

        http.rememberMe()
                .key("namhyeok") // 인증 토큰 생성시 사용할 키
                .tokenValiditySeconds(60 * 60 * 24 * 7) // 인증 토큰 유효 시간 (초)
                .userDetailsService(memberPrincipalDetailService) // 인증 토큰 생성시 사용할 UserDetailsService
                .rememberMeParameter("remember-me"); // 로그인 페이지에서 사용할 파라미터 이름

        return http.build();
    }
}

 

실행 결과입니다.

테스트 아이디 : member1 , 비밀번호 : 1111

자동로그인 체크후 로그인 시도

 

로그인 성공화면

 

자동 로그인 체크 후 remember-me 라는 쿠키가 생성, JSESSIONID를 지워도 remember-me 가 있어 다시 생성됨

 

로그인 성공 후 담겨있는 데이터를 model에 담은뒤 thymeleaf로 출력

 

다음 Spring Security 글은 DB에 저장되어있는 권한을 동적으로 부여하는 법에 대해 작성하겠습니다.

두서 없는글 읽어주셔서 감사합니다.

 

 

 

GitHub - wjsskagur/spring_security1

Contribute to wjsskagur/spring_security1 development by creating an account on GitHub.

github.com

 

+ Recent posts