이번 스프링 시큐리티는 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
다음 Spring Security 글은 DB에 저장되어있는 권한을 동적으로 부여하는 법에 대해 작성하겠습니다.
두서 없는글 읽어주셔서 감사합니다.
GitHub - wjsskagur/spring_security1
Contribute to wjsskagur/spring_security1 development by creating an account on GitHub.
github.com