Spring Security 로 회원가입, 로그인 기능 구현하기

Kyu0·2022년 11월 11일
0

Spring Boot

목록 보기
1/4
post-thumbnail

개발환경 💻

  • 운영체제 : macOS montery 12.4
  • Spring F/W
    • Spring Boot 2.7.5(Java)
    • Spring Boot Starter Security 2.7.5 (Spring Security 5.7.4)
    • Spring Boot Starter JPA 2.7.5
    • Lombok 1.8.24
    • 그 외 기타 dependency 는 생략
  • 데이터베이스 : MySQL
  • Java 11 (JDK : Amazon corretto)

구현할 기능 정의 ✏️

  • 회원 가입
  • 로그인
  • 페이지 별 접근 권한 설정

데이터베이스 테이블 설계 💽

로그인 기능 구현에 집중할 예정이므로 꼭 필요한 아이디비밀번호, 권한 만을 포함하도록 설계했습니다.

소스 코드

  • Java 소스 코드
// Member.java
import java.beans.Transient;

import javax.persistence.Entity;
import javax.persistence.Id;

import com.kyu0.jungo.member.authority.MemberAuthority;

import lombok.*;

/**
 * 1. JPA 를 이용하기 위해 기본 생성자를 Lombok 으로 선언 (@NoArgsConstructor)
 * 2. 데이터베이스 테이블과 매핑되는 클래스임을 선언 (@Entity)
 */
@NoArgsConstructor
@Getter
@Entity(name = "MEMBER")
public class Member {
	// id 컬럼을 MEMBER 테이블의 기본키로 설정
    @Id
    private String id;
    private String password;
    private MemberAuthority authority;

    @Builder
    public Member(String id, String password, MemberAuthority authority) {
        this.id = id;
        this.password = password;
        this.authority = authority;
    }
}

DTO(Data Transfer Object) 클래스는 효율적인 관리를 위해 Member 클래스의 내부 정적 클래스(inner static class)로 선언했습니다.

이외에 생성한 DTO 클래스, MemberAuthority 클래스, MemberAuthrotiyConverter 클래스는 중요하지 않다고 판단해 생략하겠습니다. 게시글 최하단에 Github 프로젝트 파일들을 올려놨으니 궁금하신 분들은 방문하시면 되겠습니다.

# resources/application.properties

## Spring JDBC 연동 정보 설정
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/jungo?characterEncoding=UTF-8&serverTimeZone=UTC
spring.datasource.username=DB 사용자명
spring.datasource.password=DB 비밀번호

## Spring Data JPA
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create # 데이터베이스 생성 쿼리를 보기 위해 create 로 설정했습니다.

결과

  • hibernate 번역 결과
create table member (id varchar(255) not null
	, authority varchar(255)
    , password varchar(255), primary key (id))
engine=InnoDB
  • MySQL

테이블이 예상한대로 잘 만들어졌습니다.👍


Spring Security 설정

Spring Security 를 거쳐 로그인을 할 것이기 때문에 Spring Security 에 필요한 설정을 하겠습니다.
XML 파일을 통해서도 해당 설정을 진행할 수 있지만 Java 를 이용한 설정이 가독성과 편의성이 높고, XML Configuration 과 비교했을 때 별다른 단점이 없다고 판단해 Java Configuration 방식으로 진행했습니다.

또한, Spring Security 5.7.0-M2 버전부터는 component-based security configuration 을 권장하기 위해, 기존에 사용했던 WebSecurityConfigurerAdapter 라는 추상 클래스가 *deprecated 상태로 되었으며 대신 SecurityFilterChain 을 등록하도록 업데이트되었습니다.

(*deprecated : 원래 지원했던 기능이 버전이 갱신되면서 더 이상 지원하지 않는 상태, 혹은 사용하지 않는 것을 권장하는 상태)

이에 따라, WebSecurityConfigurerAdapter 를 상속받아 메소드를 재정의하는 방식에서 SecurityFilterChain 클래스를 반환하는 메소드와 비밀번호 암호화에 필요한 BCryptPasswordEncoder 클래스를 Bean 으로 등록하는 방식으로 진행하겠습니다.

소스 코드

// SecurityConfiguration.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
            .authorizeRequests() // 요청에 대한 권한 설정
            .antMatchers("/").authenticated()
            .anyRequest().permitAll();

        httpSecurity
            .formLogin() // Form Login 설정
                .loginPage("/login")
                .loginProcessingUrl("/api/login")
                .defaultSuccessUrl("/")
            .and()
                .logout()
            .and()
                .csrf().disable();

        return httpSecurity.build();
    }

    @Bean
    public PasswordEncoder getPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

이제 메인 페이지인 / 페이지는 인증받은 사용자만 접근할 수 있으며, 인증받지 않은 사용자가 접근할 경우 로그인 페이지(/login) 로 이동합니다.

또한, 사용자가 입력한 로그인 폼을 /api/login 으로 POST method로 전송하면 UsernamePasswordAuthenticationFilter 를 거쳐 로그인을 하게 되었습니다.


IndexController 및 HTML 파일 작성

사용자가 요청한 페이지를 반환해주는 Controller 인 IndexController 와 해당 HTML 파일을 작성하겠습니다.

소스 코드

// IndexController.java

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class IndexController {
    
    @GetMapping("/")
    public String main() {
        return "main";
    }

    @GetMapping("/login")
    public String login() {
        return "login";
    }

    @GetMapping("/register")
    public String register() {
        return "register";
    }
}

이후에 회원가입 기능도 추가할 예정이므로 /register 경로를 처리하는 메소드도 추가했습니다. HTML 파일은 중요한 부분이 아니라고 판단해 본 게시글에서는 생략하겠습니다.

이제 인증받지 않은 사용자가 메인 페이지(localhost:8080/) 에 접근하면 로그인 페이지를 반환하거나 회원가입 후 로그인 페이지로 접근할 수 있게 되었습니다.


Controller, Service, Repository 작성

그 다음으로 회원가입 요청과 로그인 요청을 처리하기 위해 Controller, Service, Repository 를 작성하겠습니다.

소스 코드

// MemberApiController.java
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MemberApiController {

    private final MemberService memberService;

    public MemberApiController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PostMapping("/api/member")
    public void save(@RequestBody Member.SaveRequest member) {
        memberService.save(member);
    }
}
// MemberService.java
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class MemberService implements UserDetailsService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    public MemberService(MemberRepository memberRepository, PasswordEncoder passwordEncoder) {
        this.memberRepository = memberRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public void save(Member.SaveRequest member) {
        member.setPassword(passwordEncoder.encode(member.getPassword()));

        memberRepository.save(member.toEntity());
    }

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findById(username)
            .orElseThrow(() -> new UsernameNotFoundException("username"));
            
        return toUserDetails(member);
    }

    private UserDetails toUserDetail(Member member) {
        return User.builder()
            .username(member.getId())
            .password(member.getPassword())
            .authorities(new SimpleGrantedAuthority(member.getAuthority().getName()))
        .build();
    }
}

추가로, 로그인 로직을 완성하기 위해 MemberService 클래스는 UserDetailsService 인터페이스를 구현했습니다.

이로써 AuthenticationProvierMemberService 클래스의 loadUserByUsername(String username) 메소드를 호출하여 사용자가 보낸 정보와 loadUserByUsername(String username) 에서 반환받은 UserDetails 가 같은지 검사할 수 있게 되었습니다.

기회가 된다면 다음에 세부적인 과정을 다루는 포스트를 올리도록 하겠습니다.

// MemberRepository.java

import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, String> {
    
}

결과

  • PostMan 실행 결과

이제 사용자의 회원가입 요청을 받아 MemberApiControllerMemberServiceMemberRepository 를 거쳐 데이터베이스에 저장할 수 있게 되었습니다.


테스트 👷

이제 통합 테스트를 다음과 같이 진행하겠습니다.

1번 테스트. 메인 페이지 접속

  • 예상 결과 : 로그인 페이지로 이동

2번 테스트. 회원가입로그인

  • 예상 결과 : 로그인 성공 후 메인 페이지로 이동

3번 테스트. 회원가입틀린 비밀번호 입력

  • 예상 결과 : 로그인 실패

1번 테스트

  1. 메인 페이지 접속

2번 테스트

  1. 회원 가입

  • 입력값 (id : test, password : 12341234)
  1. 로그인 성공 후 메인 페이지로 이동

3번 테스트

  1. 회원 가입
  • 입력값 (id : test2, password : 123123)
  1. 틀린 비밀번호 입력
  • 입력값 (id : test2, password : 1111)

구현 중 발생한 오류

-JSON parse error

오류 인식

Spring Boot 서버로 데이터를 보낼 때 body 부분을 JSON 형식으로 변환하지 않아 생긴 오류였습니다.

해결

위의 코드에서 body 부분의 데이터를 JSON.stringfy() 함수를 통해 JSON 형식으로 변환시켜 오류를 해결했습니다.


-로그인 요청 시 There is no PasswordEncoder mapped for the id "null"

오류 인식

PasswordEncoderBean 으로 등록하지 않아 AuthenticationProvider 가 로그인 요청 데이터를 검증하는 과정에서 오류가 발생한 것이었습니다.

Stack trace 를 살펴보면 DaoAuthenticationProvider.additionalAuthenticationCheckes()DelegatingPasswordEncoder.matches()PasswordEncoder.matches() 순으로 함수 호출이 이뤄지는 것을 알 수 있었습니다.

그리고 DelegatingPassworndEncoder 가 초기화될 때 가지고 있는 UnmappedIdPasswordEncodermatches() 함수에서 예외를 던지게 되는 것도 알 수 있었습니다.

해결 방법

따라서, BCryptPasswordEncoderBean 으로 등록하여 Spring Security 프레임워크가 해당 BCryptPasswordEncoder 를 인식하고 사용할 수 있도록 하여 오류를 해결했습니다.

(* Bean 으로 등록하기만 해도 DelegatingPasswordEncoderBCryptPasswordEncoder 로 교체되는 이유는, Spring Security 의 초기화 과정에서 ApplicationContext 에서 Bean 으로 등록된 PasswordEncoder 가 있다면 해당 PasswordEncoder 를 주입하기 때문입니다. [관련 글])


- 로그인 시 Request method 'POST' not supported 발생

오류 인식

Form Login 설정을 하는 과정에서 successForwardUrl() 메소드를 사용했는데, 해당 메소드는 로그인 후 이동할 페이지를 설정하는 용도가 아니라 로그인 후 추가적인 로직을 거치기 위한 url을 설정하는 용도로 사용되는 메소드입니다.

때문에, POST 메소드로 로그인이 요청이 들어오면 {method: 'GET', url: '/'} 가 아닌 {method: 'POST', url: '/'} 형태로 url에 접근하기 때문에 오류가 나는 것이었습니다.

해결 방법

정상적으로 메인 페이지로 갈 수 있게끔 successForwardUrl() 메소드를 defaultSuccessUrl() 메소드로 변경해 오류를 해결했습니다.


Github 주소 : https://github.com/Kyu0/jungo/commit/334ac90d71916f7f36f2840bf0fde1924e46c7fc

잘못된 내용, 오타 지적 언제나 환영입니다.

profile
개발자

0개의 댓글