[Spring Boot] 로그인과 로그아웃

DANI·2023년 10월 14일
0
post-thumbnail

앞서 회원가입기능을 구현하였다. 이제 로그인과 로그아웃 기능을 구현해보자!



❓ 로그인 로그아웃 기능을 구현하려면 무엇이 필요할까?

📝 스프링 시큐리티를 사용하면 이 단계를 보다 쉽게 진행할 수 있다.


💾 스프링 시큐리티에 로그인 URL/로그아웃 URL 등록

package com.mysite.sbb;

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.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
      http
          .authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
              .requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
          .csrf((csrf) -> csrf
          		.ignoringRequestMatchers(new AntPathRequestMatcher("/h2-colsole/**")))
          .headers((headers) -> headers
                  .addHeaderWriter(new XFrameOptionsHeaderWriter(
                      XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
          .formLogin((formLogin) -> formLogin
                  .loginPage("/user/login")
                  .defaultSuccessUrl("/"))
		  .logout((logout) -> logout
              .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
              .logoutSuccessUrl("/")
              .invalidateHttpSession(true))
      ;
      return http.build();
  }
	
	@Bean
  PasswordEncoder passwordEncoder() {
      return new BCryptPasswordEncoder();
  }
}

✅ 추가된 부분

// .formLogin은 스프링 시큐리티의 로그인 설정을 담당하는 메서드
.formLogin((formLogin) -> formLogin
				// 로그인페이지의 url은 "/user/login"
              .loginPage("/user/login")
				// 로그인 성공 시 이동하는 페이지는 루트
              .defaultSuccessUrl("/"))
// 로그아웃 URL을 "/user/logout"으로 설정
.logout((logout) -> logout
              .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
				// 성공하면 루트 페이지로 이동
              .logoutSuccessUrl("/")
				// 로그아웃 시 생성된 사용자 세션 삭제
              .invalidateHttpSession(true))

🔴 .formLogin 메서드

public HttpSecurity formLogin(Customizer<FormLoginConfigurer<HttpSecurity>> formLoginCustomizer) throws Exception {
		formLoginCustomizer.customize(getOrApply(new FormLoginConfigurer<>()));
		return HttpSecurity.this;
	}

🔴 .logout 메서드

public HttpSecurity logout(Customizer<LogoutConfigurer<HttpSecurity>> logoutCustomizer) throws Exception {
		logoutCustomizer.customize(getOrApply(new LogoutConfigurer<>()));
		return HttpSecurity.this;
	}




url을 /user/login으로 해줬으니 컨트롤러에서 매핑을 해주자!

💾 UserController에 추가하기

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

login_form.html 템플릿을 렌더링하는 GET 방식의 login 메서드를 추가했다. 실제 로그인을 진행하는 @PostMapping 방식의 메서드는 스프링 시큐리티가 대신 처리하므로 직접 구현할 필요가 없다.

💾 login_form.html 생성하기

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
  <form th:action="@{/user/login}" method="post">
      <div th:if="${param.error}">
          <div class="alert alert-danger">
              사용자ID 또는 비밀번호를 확인해 주세요.
          </div>
      </div>
      <div class="mb-3">
          <label for="username" class="form-label">사용자ID</label>
          <input type="text" name="username" id="username" class="form-control">
      </div>
      <div class="mb-3">
          <label for="password" class="form-label">비밀번호</label>
          <input type="password" name="password" id="password" class="form-control">
      </div>
      <button type="submit" class="btn btn-primary">로그인</button>
  </form>
</div>
</html>

시큐리티의 로그인이 실패할 경우에는 로그인 페이지로 다시 리다이렉트 된다. 이 때 페이지 파라미터로 error가 함께 전달된다. 따라서 로그인 페이지의 파라미터로 error가 전달될 경우 "사용자ID 또는 비밀번호를 확인해 주세요." 라는 오류메시지를 출력하도록 했다.

✅ 로그인 실패시 파라미터로 error가 전달되는 것은 스프링 시큐리티의 규칙이다.




하지만 아직 로그인을 수행할 수는 없다. 왜냐하면 스프링 시큐리티에 무엇을 기준으로 로그인을 해야 하는지 아직 설정하지 않았기 때문이다. 스프링 시큐리티를 통해 로그인을 수행하는 방법에는 여러가지가 있지만, 우리는 이미 회원가입을 통해 회원 정보를 데이터베이스에 저장했으므로 데이터베이스에서 회원정보를 조회하는 방법을 사용해야 한다.

💾 UserRepository 메소드 추가

package com.mysite.sbb.user;

import java.util.Optional;

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

public interface UserRepository extends JpaRepository<SiteUser, Long> {
  Optional<SiteUser> findByusername(String username);
}


💾 UserRole 생성

package com.mysite.sbb.user;

import lombok.Getter;


@Getter
public enum UserRole {
  ADMIN("ROLE_ADMIN"),
  USER("ROLE_USER");

  UserRole(String value) {
      this.value = value;
  }

  private String value;
}

✅ 스프링 시큐리티는 인증 뿐만 아니라 권한도 관리한다.

따라서 인증후에 사용자에게 부여할 권한이 필요하다. 다음과 같이 ADMIN, USER 2개의 권한을 갖는 UserRole을 신규로 작성하자.

UserRole은 열거 자료형(enum)으로 작성했다. ADMIN은 "ROLE_ADMIN", USER는 "ROLE_USER" 라는 값을 가지도록 했다. 그리고 상수 자료형이므로 @Setter없이 @Getter만 사용가능하도록 했다.



💾 UserSecurityService 생성

package com.mysite.sbb.user;

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

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 org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service

// UserDetailsService인터페이스를 구현해야 한다.
public class UserSecurityService implements UserDetailsService  {
	private final UserRepository userRepository;
	
    // 반드시 재정의 해야함. 
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
		
		// 입력받은 사용자 명으로 _siteUser 변수를 가진 Optinal타입의 객체 생성
		Optional<SiteUser> _siteUser = this.userRepository.findByUsername(username);
		
		// 만약 사용자가 없다면 에러 생성
		if(_siteUser.isEmpty()) {
			throw new UsernameNotFoundException("사용자를 찾을 수 없습니다.");
		}
		
		// Optional<SiteUser>타입을 SiteUser 엔티티 타입으로 형변환
		SiteUser siteuser = _siteUser.get();
		
		// 사용자 정보를 담을 객체 생성
		List<GrantedAuthority> authorities = new ArrayList<>();
		
		// 입력받은 사용자 명을 통해 인증 받은 사용자 정보를 담을 객체에 권한을 추가한다.
		// 사용자 명이 "admin"일 경우 ADMIN권한, 그 외의 경우 "USER" 권한 부여
		if("admin".equals(username)) {
			authorities.add(new SimpleGrantedAuthority(UserRole.ADMIN.getValue()));
		} else {
			authorities.add(new SimpleGrantedAuthority(UserRole.USER.getValue()));
		}
		return new User(siteuser.getUsername(), siteuser.getPassword(), authorities);
	}
}

스프링 시큐리티에 등록하여 사용할 UserSecurityService는 스프링 시큐리티가 제공하는 UserDetailsService 인터페이스를 구현(implements)해야 한다.

🔴 UserDetailsService 인터페이스

public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

스프링 시큐리티의 UserDetailsService는 loadUserByUsername 메서드를 구현하도록 강제하는 인터페이스이다. loadUserByUsername 메서드는 사용자명으로 비밀번호를 조회하여 리턴하는 메서드이다.

✅ UserSecurityService는 스프링 시큐리티 로그인 처리의 핵심 부분이다.

스프링 시큐리티는 loadUserByUsername 메서드에 의해 리턴된 User 객체의 비밀번호가 화면으로부터 입력 받은 비밀번호와 일치하는지를 검사하는 로직을 내부적으로 가지고 있다.

💡 스프링 시큐리티


https://velog.io/@dani0817/Spring-Boot-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0

7. UserDetailsService ➡ AuthenticationProvider(s)
AuthenticationProvider는 UserDetails 객체를 전달 받은 이후 실제 사용자의 입력정보와 UserDetails 객체를 가지고 인증을 시도한다.

8. AuthenticationProvider(s) ➡ AuthenticationManager implements ProviderManager
인증이 완료가되면 권한 등의 사용자 정보를 담은 Authentication 객체를 반환한다.(or AuthenticationException)

9. AuthenticationManager implements ProviderManager ➡ AuthenticationFilter
인증이 끝났고, 다시 최초의 AuthenticationFilter에 Authentication 객체가 반환된다.





💾 스프링 시큐리티에 빈 추가

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
}

AuthenticationManager 빈을 생성했다. AuthenticationManager는 스프링 시큐리티의 인증을 담당한다. AuthenticationManager는 사용자 인증시 앞에서 작성한 UserSecurityService와 PasswordEncoder를 사용한다.




💾 네비게이션 바에 로그인 링크 추가하기

✅ 수정 전 : <a class="nav-link" href="#">로그인</a>
✅ 수정 후 : <a class="nav-link" th:href="@{/uesr/login}">로그인</a>



💻 실행화면




🚫 로그인에 성공했지만 로그인 메뉴가 보인다



사용자의 로그인 여부는 타임리프의 sec:authorize 속성을 통해 알수 있다.

  • sec:authorize="isAnonymous()" - 이 속성은 로그인 되지 않은 경우에만 해당 엘리먼트가 표시되게 한다.
  • sec:authorize="isAuthenticated()" - 이 속성은 로그인 된 경우에만 해당 엘리먼트가 표시되게 한다.


💾 네비게이션 바 수정하기

✅ 수정 전 : <a class="nav-link" th:href="@{/uesr/login}">로그인</a>
✅ 수정 후 : <a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a> <a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>

💻 실행화면

  • 로그인을 안한 상태라면 sec:authorize="isAnonymous()"가 참이되어 "로그인" 링크가 표시되고
  • 로그인을 한 상태라면 sec:authorize="isAuthenticated()"가 참이되어 "로그아웃" 링크가 표시된다.




✨ 이번 챕터에서 배운 부분

✅ 로그인(.forLogin), 로그아웃(.logout) 기능 구현
✅ 스프링 시큐리티의 권한 관리
✅ 타임리프의 sec:authorize 속성

📝 공부할 부분

✅ 스프링 시큐리티에 대한 deep한 공부 필요 / 점프 투 스프링부트 끝난 후 공부 예정
✅ 타임리프 속성에 대한 공부

0개의 댓글