프로그래밍/Spring

[Spring] Spring Security 회원가입/로그인 구현 예제

브랜치 미정 2023. 9. 18. 16:07

Spring Security로 회원가입 / 로그인을 처리 하는 예제는  아래와 같다.

 

  1. Spring Security 라이브러리 추가
  2. 시큐리티 설정을 할 SpringSecurity 클래스 생성
  3. 사용자 정보를 담는 UserDetails를 구현 한 PrincipalDetails 클래스 생성
  4. 시큐리티에서 유저의 정보를 가져오는 UserDetailsService를 구현 한 PrincipalDetailsService 클래스 생성
  5. 유저 정보를 담을 User 도메인 클래스 생성
  6. 회원가입 시 주고 받을 UserSaveRequestDto 생성
  7. User를 저장 할 UserRepository 클래스 생성
  8. 회원가입 화면을 띄워줄 UserController 클래스 생성
  9. 회원가입 기능을 구현 할 UserService 클래스 생성
  10. 회원가입 Post 요청을 처리 할 UserApiController 클래스 생성
  11. 회원가입 화면 user-save.html 생성
  12. 회원가입 Post 요청을 보낼 user-save.js 생성
  13. 로그인 화면을 띄워줄 LoginController 클래스 생성
  14. 로그인 화면 login.html 생성

Spring Security 라이브러리 추가

  • gradle 기준
	implementation 'org.springframework.boot:spring-boot-starter-security'
	testImplementation 'org.springframework.security:spring-security-test'

SpringSecurity

  • WebSecurityConfigureAdaper를 상속 받은 시큐리티 설정 클래스
  • 암호화를 위한 BCryprPasswordEncoder를 Bean으로 등록하고
  • andMatchers 로 인증절차 없이 접근할 수 있는 경로를 설정해주고
  • formLogin, loginPage로 로그인 페이지를 설정해주고
  • login, logout 성공시 경로를 설정해준다.
@Configuration
@EnableWebSecurity
public class SpringSecurity extends WebSecurityConfigurerAdapter {
    @Autowired
    private PrincipalDetailsService principalDetailsService;

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .headers().frameOptions().disable()
                .and()
                    .authorizeRequests()
                    .antMatchers("/",
                            "/css/**",
                            "/js/**",
                            "/api/v1/**").permitAll()
                .and()
                    .formLogin()
                    .loginPage("/login")
                    .defaultSuccessUrl("/", true)
                .and()
                    .logout()
                    .logoutSuccessUrl("/");
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception{
        auth.userDetailsService(principalDetailsService).passwordEncoder(passwordEncoder());
    }
}

 

PrincipalDetails

 

  • 사용자 정보를 담는 UserDetails를 구현한 클래스, 입맛에 맞게 구현
  • getAuthorities() : 권한 목록을 리턴한다.
  • getPassword() : 계정의 비밀번호를 리턴한다.
  • getUsername() : 계정의 이름을 리턴한다.
  • isAccountNonExpired() : 계정이 만료되지 않았는지 리턴한다 (true : 만료 안됨)
  • isAccountNonLocked() : 계정이 잠겨있지 않았는지 리턴한다. (true : 잠기지 않음)
  • isCredentialNonExpired() : 비밀번호가 만료되지 않았는 지 리턴한다 (true : 만료안됨)
  • isEnabled() : 계정이 사용가능인지 리턴한다. (true : 가능)
public class PrincipalDetails implements UserDetails {
    private User user;

    public PrincipalDetails(User user){
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

PrincipalDetailsService

  • DB에서 유저 정보를 가져 오는 UserDetailsService를 구현한 클래스
  • userRepository에서 유저를 가져와 PrincipalDetails 형으로 정보를 가져온다.
@RequiredArgsConstructor
@Service
public class PrincipalDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username).orElseThrow(() -> new IllegalArgumentException("유저가 없습니다. name=" + username));

        return new PrincipalDetails(user);
    }
}

User

  • 유저 정보를 담을 도메인 클래스
  • username,  password 를 가진다
@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    private String username;
    private String password;
    
    //생략
}

 

UserSaveRequestDto

  • 회원가입 시 사용할 DTO
@Getter
@NoArgsConstructor
public class UserSaveRequestDto {
    private String username;
    private String password;

    @Builder
    public UserSaveRequestDto(String username, String password){
        this.username = username;
        this.password = password;
    }
}

UserRepository

  • 데이터베이스에 접근하여 User 엔티티를 저장할 클래스
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

}

UserService

  • 회원가입 기능을 구현 할 Service 클래스
  • passwordEncoder를 이용하여 password를 암호화 하여 저장한다.
@RequiredArgsConstructor
@Service
public class UserService {
    private final UserRepository userRepository;

    private final BCryptPasswordEncoder passwordEncoder;

    @Transactional
    public Long save(UserSaveRequestDto userSaveRequestDto){

        User user = User.builder()
                .username(userSaveRequestDto.getUsername())
                .password(passwordEncoder.encode(userSaveRequestDto.getPassword()))
                .build();

        return userRepository.save(user).getId();
    }
    
    // 생략
}

UserController

  • 회원가입 화면을 띄워 줄 Controller 클래스
@RequiredArgsConstructor
@Controller
public class UserController {

    private final UserService userService;

    @GetMapping("/user/save")
    public String userSave(Model model){
        model.addAttribute("userSaveRequestDto", new UserSaveRequestDto("", ""));
        return "user-save";
    }
}

user-save.html

  • 회원가입 화면
  • userSaveRequestDto 에 username, password를 담아서 button을 클릭하면 user-save.js에 선언 한 userSave() 함수를 실행한다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<html lang="en">
<head th:replace="fragments/header :: header">
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
    <h1>회원가입</h1>

    <form id="user-save-form" th:object="${userSaveRequestDto}">
        <input type="text" th:field="*{username}" required>
        <input type="password" th:field="*{password}" required>
        <button type="submit" onclick="userSave()">회원가입</button>
    </form>

    <div th:replace="fragments/footer :: footer" />

    <script src="/js/user-save.js"></script>
</body>
</html>

user-save.js

  • user-save.html 에서 입력한 username, password를 JSON타입으로  /api/v1/user 경로로 post 요청을 보낸다.
function userSave(){
    const form = document.getElementById("user-save-form");
    const formData = new FormData(form);

    const userSaveRequestDto = {
        username: formData.get('username'),
        password: formData.get('password')
    };

    event.preventDefault();

    $.ajax({
        type: 'POST',
        url: '/api/v1/user',
        dataType: 'json',
        contentType: 'application/json; charset=utf-8',
        data: JSON.stringify(userSaveRequestDto)
    }).done(function(){
        alert('회원가입 완료');
        window.location.href = '/';
    }).fail(function(error){
        alert(JSON.stringify(error));
    })
}

UserApiController

  • 회원가입 Post 요청을 처리할 클래스
  • user-save.js 에서 보낸 Dto를 RequestBody로 받아서 Service에서 구현한 save를 실행한다.
@RequiredArgsConstructor
@RestController
public class UserApiController {
    private final UserService userService;

    @PostMapping("/api/v1/user")
    public Long save(@RequestBody @Valid UserSaveRequestDto userSaveRequestDto){
        return userService.save(userSaveRequestDto);
    }
}

LoginController

  • 로그인 화면을 띄워줄 Controller 클래스
@RequiredArgsConstructor
@Controller
public class LoginController {
    @GetMapping("/login")
    public String login(){
        return "login";
    }
}

Login.html

  • 로그인 화면
  • SpringSecurity에서 loginPage 경로를 이 로그인 화면으로 설정하였으므로 해당 화면에서 폼을 제출하면 로그인 처리가 진행된다.
  • username, password로 post 요청을 보낸다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<html lang="en">
<head th:replace="fragments/header :: header">
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<h1>로그인</h1>

<form th:method="post">
    <input type="text" name="username" required>
    <input type="password" name="password" required>
    <button type="submit">로그인</button>
</form>

<div th:replace="fragments/footer :: footer" />

</body>
</html>