[DEV] J-Jay

스프링 부트 - 로그인/로그아웃 구현 본문

Back-end/Spring

스프링 부트 - 로그인/로그아웃 구현

J-Jay 2023. 9. 10. 17:43
728x90

스프링 시큐리티 설정

 

build.gradle (의존성 추가)

dependencies {
    //스프링 시큐리티를 사용하기 위한 스타터 의존성
    implementation 'org.springframework.boot:spring-boot-starter-security'
    //타임리프에서 스프링 시큐리티를 사용하기 위한 의존성
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    //스프링 시큐리티를 테스트하기 위한 의존성
    testImplementation 'org.springframework.security:spring-security-test'
}

스프링 시큐리티를 사용하기 위해 build.gradle파일에 의존성을 위와 같이 추가한다.

 

User.java (회원 엔티티)

@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "email", nullable = false, unique = true)
    private String email;

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

    @Builder
    public User(String email, String password, String auth){
        this.email = email;
        this.password = password;
    }

    @Override //권한 반환
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("user"));
    }

    @Override //사용자 패스워드 반환
    public String getPassword() {
        return password;
    }

    @Override //사용자 id 반환 (고유값)
    public String getUsername() {
        return email;
    }

    @Override //계정 만료 여부
    public boolean isAccountNonExpired() {
        //만료 되었는지 확인 true: 만료 X / false : 만료
        return true;
    }

    @Override //계정 잠금이 안되어있는지 여부
    public boolean isAccountNonLocked() {
        //true : 잠금 안됨 / false : 잠금 O
        return true;
    }

    @Override //패스워드 만료 안되었는지 여부
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override //계정 사용 가능 여부
    public boolean isEnabled() {
        return false;
    }
}

User라는 회원 엔티티를 생성하고, UserDetails클래스를 상속한다.

UserDetails클래스는 스프링 시큐리티에서 사용자의 인증 정보를 담아 두는 인터페이스이다.

스프링 시큐리티에서 해당 객체를 통해 인증  정보를 가져오기에, 필수 오버라이드 메소드를 정의해야한다.

 

 

UserRepository.java

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

이메일로 사용자를 식별할 수 있다. 즉, 사용자 이름으로 봐도 무방하기에 사용자 정보를 가져오기 위해서 스프링 시큐리티에 이메일 값을 전달한다.

스프링 데이터 JPA는 메서드 규칙에 맞춰 메서드를 선언하면 이름을 분석해 자동으로 쿼리를 생성해준다.

findBtEamil()메서드는 실제 데이터베이스에 회원저옵를 요청할 때 다음 쿼리를 실행한다.

 

FROM users
WHERE email = #{email}

 

UserDetailService.java

@RequiredArgsConstructor
@Service
public class UserDetailService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public User loadUserByUsername(String email) {
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException((email)));
    }
}

엔티티와 레포지토리를 생성하고난 후에는 로그인을 진행할 때 사용자 정보를 가져오는 코드가 필요하다.

위와 같이 스프링 시큐리티에서 사용자의 정보를 가져오는 UserDetailService 인터페이스를 구현하고,

필수로 구현해야하는 loadUserBtUsername() 메서드를 오버라이딩해서 사용자 정보를 가져오는 로직을 작성한다.

 

WebSecurityConfig.java

@RequiredArgsConstructor
@Configuration
public class WebSecurityConfig {

    private final UserDetailService userService;

    @Bean //스프링 시큐리티 기능 비활성
    public WebSecurityCustomizer configure() {
        return (web) -> web.ignoring()
                .requestMatchers(toH2Console())
                .requestMatchers("/static/**");
    }

    @Bean //특정 HTTP 요청에 대한 웹 기반 보안
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeRequests() //인증, 인가 설정
                .requestMatchers("/login", "/signup", "/user").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()// form 기반 로그인 설정
                .loginPage("/login")
                .defaultSuccessUrl("/articles")
                .and()
                .logout()// logout 설정
                .logoutSuccessUrl("/login")
                .invalidateHttpSession(true)
                .and()
                .csrf().disable() //csrf 비활성화
                .build();
    }

    @Bean //인증 관리자 설정
    public AuthenticationManager authenticationManager(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailService userDetailService) throws Exception {
        return http.getSharedObject(AuthenticationManagerBuilder.class)
                .userDetailsService(userService)// 사용자 정보 서비스 설정
                .passwordEncoder(bCryptPasswordEncoder)
                .and()
                .build();
    }

    @Bean //패스워드 인코더로 사용할 빈
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

인증을 위한 도메인, 레포지터리, 서비스를 완성했으니 실제 인증처리에 필요한 시큐리티 설정 파일을 위와같이 생성한다.

 

confgiure()

  • 스프링 시큐리티의 모든 기능을 사용하지 않게 설정하는 코드이다. 정적 리소스(이미지, HTML)에는
    인증, 인가 서비스를 사용하지 않기에 위와 같이 설정한다.

filterChane()

  • 특정 HTTP요청에 대해 웹기반 보안을 구성한다.
    이 메서드에서 인증/인가 및 로그인, 로그아웃 관련 설정이 가능하다.

authorizeRequest()

  • 특정 경로에 대한 액세스 설정을 한다.
  • requestMatchers(): 특정 요청과 일치하는 url에 대한 액세서를 설정한다.
  • permitAll(): 누구나 접근이 가능하게 설정한다 /login, /signup, /user로 요청이 오면 인증/인가 없이 접근 가능하다.
  • anyRequest(): 위에서 설정한 url이외의 요청에 대해 설정한다
  • authenticated(): 별도의 인가는 필요하지 않지만 인증이 접근할 수 있다

formLogin()

  • form 기반 로그인 설정을 한다
  • loginPage(): 로그인 페이지 경로 설정
  • defaultSuccessUrl():로그인이 완료되었을 때 이동할 경로 설정

logout()

  • 로그아웃 설정이다.
  • logoutSuccessUrl(): 로그아웃이 완료되었을 때 이동할 경로를 설정
  • invalidateHttpSession(): 로그아웃 이후에 세션을 전체 삭제할지 여부 설정

csrf.disable()

  •  csrf공격을 비활성화해 두는 것이다. (원래라면 활성화 해야 좋다)

authenticationManger()

  • 인증 관리자 관련 설정이다.
    사용자 정보를 가져올 서비스를 재정의하거나, 인증 방법 (LDAP, JDBC 기반 인증 등)을 설정할 때 사용한다.

userDetailService()

  • 사용자 서비스를 설정한다
  • userDetailsService(): 사용자 정보를 가져올 서비스를 설정한다. 이때 설정하는 서비스 클래스는 반드시 UserDetailsService를 상속받은 클래스여야 한다.
  • passwordEncoder(): 비밀번호를 암호화하기 위한 인코더를 설정한다.

bCryptPasswordEncoder()

  • 패스워드 인코더를 빈으로 등록한다

회원가입 구현

 

AddUserRequest.java (DTO)

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Setter
@Getter
public class AddUserRequest {
    private String email;
    private String password;
}

UserService.java

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public Long save(AddUserRequest dto) {
        return userRepository.save(User.builder()
                .email(dto.getEmail())
                .password(bCryptPasswordEncoder.encode(dto.getPassword()))
                .build()).getId();
    }
}

AddRequest 객체를 인수로 받을 회원정보 메서드 추가하여

패스워드를 저장할 때 시큐리티를 설정하며 패스워드 인코딩용으로 등록한 빈을 사용해서 암호화 한 후 저장한다.

 

UserApiController.java

@RequiredArgsConstructor
@Controller
public class UserApiController {

    private final UserService userService;

    @PostMapping("/user")
    public String signup(AddUserRequest request) {
        userService.save(request);
        return "redirect:/login";
    }

}

 

회원가입 폼에서 회원가입 요청을 받으면 서비스 메서드를 사용해 사용자를 저장한 뒤, 로그인 페이지로 이동한다.

회원가입 처리가 된 다음 로그인 페이지로 이동하기 위해 redirect: 접두사를 사용했다.

이렇게 하면 회원 가입 처리가 끝나면 강제로 /login URL에 해당하는 화면으로 이동한다.


회원가입, 로그인 화면 구현

 

UserViewController.java

@Controller
public class UserViewController {

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

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

}

로그인, 회원가입 경로로 접근하면 뷰 파일을 연결하는 컨트롤러이다.

/login 경로로 접근하면 login() 메서드가 login.html을, /signup 경로에 접근하면 signup() 메서드는 signup.html을 반환한다.

 

login.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">

    <style>
    .gradient-custom {
      background: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1))
    }
  </style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
    <div class="container-fluid row justify-content-center align-content-center">
        <div class="card bg-dark" style="border-radius: 1rem;">
            <div class="card-body p-5 text-center">
                <h2 class="text-white">LOGIN</h2>
                <p class="text-white-50 mt-2 mb-5">로그인 부탁드립니다.</p>

                <div class = "mb-2">
                    <form action="/login" method="POST">
                        <input type="hidden" th:name="${_csrf?.parameterName}" th:value="${_csrf?.token}" />
                        <div class="mb-3">
                            <label class="form-label text-white">Email address</label>
                            <input type="email" class="form-control" name="username">
                        </div>
                        <div class="mb-3">
                            <label class="form-label text-white">Password</label>
                            <input type="password" class="form-control" name="password">
                        </div>
                        <button type="submit" class="btn btn-primary">Submit</button>
                    </form>

                    <button type="button" class="btn btn-secondary mt-3" onclick="location.href='/signup'">회원가입 go!</button>
                </div>
            </div>
        </div>
    </div>
</section>
</body>
</html>

signup.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>회원 가입 페이지</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">

    <style>
    .gradient-custom {
      background: linear-gradient(to right, rgba(254, 238, 229, 1), rgba(229, 193, 197, 1))
    }
  </style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
    <div class="container-fluid row justify-content-center align-content-center">
        <div class="card bg-dark" style="border-radius: 1rem;">
            <div class="card-body p-5 text-center">
                <h2 class="text-white">SIGN UP</h2>
                <p class="text-white-50 mt-2 mb-5">회원 가입</p>

                <div class = "mb-2">
                    <form th:action="@{/user}" method="POST">
                        <!-- CSRF 공격 방지 -->
                        <input type="hidden" th:name="${_csrf?.parameterName}" th:value="${_csrf?.token}" />
                        <div class="mb-3">
                            <label class="form-label text-white">이메일 주소</label>
                            <input type="email" class="form-control" name="email">
                        </div>
                        <div class="mb-3">
                            <label class="form-label text-white">비밀번호 </label>
                            <input type="password" class="form-control" name="password">
                        </div>

                        <button type="submit" class="btn btn-primary">Submit</button>
                    </form>
                </div>
            </div>
        </div>
    </div>
</section>
</body>
</html>

로그아웃 구현

 

UserApiController.java

@RequiredArgsConstructor
@Controller
public class UserApiController {

    private final UserService userService;

    ...

    @GetMapping("/logout")
    public String logout(HttpServletRequest request, HttpServletResponse response) {
        new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
        return "redirect:/login";
    }

}

/logout GET 요청을 하면 로그아웃을 담당하는 핸들러인 SecurityContextLogoutHandler의 logout() 메서드를 호출해서

로그아웃 한다.

 

articleList.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>블로그 글 목록</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
    <h1 class="mb-3">My Blog</h1>
    <h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>

<div class="container">
    <button type="button" id="create-btn"
            th:onclick="|location.href='@{/new-article}'|"
            class="btn btn-secondary btn-sm mb-3">글 등록</button>
    <div class="row-6" th:each="item : ${articles}">
        <div class="card">
            <div class="card-header" th:text="${item.id}">
            </div>
            <div class="card-body">
                <h5 class="card-title" th:text="${item.title}"></h5>
                <p class="card-text" th:text="${item.content}"></p>
                <a th:href="@{/articles/{id}(id=${item.id})}" class="btn btn-primary">보러가기</a>
            </div>
        </div>
        <br>
    </div>
    <button type="button" class="btn btn-secondary" onclick="location.href='/logout'">로그아웃</button>
</div>

<script src="/js/article.js"></script>
</body>

해당 파일에 로그아웃 view 버튼을 추가한다.

'Back-end > Spring' 카테고리의 다른 글

Spring @Temproal Annotation  (0) 2024.04.24
스프링 부트 - JWT  (0) 2023.09.10
스프링 시큐리티(Security)  (0) 2023.09.09
스프링 부트 Blog 만들기 (3)  (1) 2023.09.09
스프링 부트 Blog 만들기 (2) - Thymeleaf  (0) 2023.09.05