본문 바로가기
실습/리눅스 서버 + 스프링 부트

Spring Security

by 이민우 2021. 11. 7.
728x90
반응형

로그인을 처리할 때 고려해야 할 점은, 단순히 사용자로부터 아이디와 비밀번호를 입력받아 그 값을 DB 내 사용자 테이블의 데이터와의 일치여부를 판단하는 인증 기능 하나만으로 끝이나지 않는다.

 

그렇게 로그인이 되었다면 독립적인 HTML 파일로 존재하는 모든 웹 페이지에서 로그인 정보를 유지할 수 있도록 세션을 유지해야 하고, 필요하다면 인가 기능을 통해 해당 사용자에게 보여줄 페이지와, 보여줘서는 안 될 페이지를 나눠야 한다.

 

이러한 작업은 물론 직접 코딩을 통해 구현할 수 있겠지만, 매우 귀찮은 작업이다. 그런데 Spring Security는 이러한 귀찮은 작업을 쉽게 구현할 수 있도록 도와준다.

 

 

 

Spring Security

 

Spring Security는 스프링의 파위 프레임워크로, 어플리케이션의 보안을 담당한다. 그리고 이 보안에는 위에서 언급한 세션과, 인증 기능 등을 모두 포함하고 있다.

 

즉, 로그인과 회원 정보에 관련된 기능을 쉽고 간편하게 구현할 수 있게 해준다.

사실 깊게 설명을 하면 끝이 없고, 바로 구현으로 넘어가자.

 

당연히 구현 툴은 Spring Boot이다.

다음의 기능들을 넣고 프로젝트를 생성한다.

 

가장 먼저 늘 그랬듯 application.properties를 먼저 구성.

 

 

데이터베이스는 다음과 같이 생성한다.

간단하게 설명을 하자면,

사용자가 존재하고 사용자를 그룹으로 묶어 권한을 부여할 수 있는 권한이 존재한다.

권한은 메뉴와 다대다 관계이며, 이에 따라 관계 테이블을 통해 허용 여부를 가지고 있는 구조이다.

 

 

하지만 단순 연습 페이지에서 이 DB를 전부 구현하기는 귀찮으니, 사용자와 권한_메뉴_관계 테이블만 구성하자.

 

이에 따른 Domain 테이블은 아래와 같이 구성된다.

 

AuthDomain.java

package com.example.demo.domain.auth;

import java.util.Collection;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.Table;

import org.springframework.security.core.GrantedAuthority;

import com.example.demo.domain.user.UserDomain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Table(name="auth_domain")
@IdClass(AuthDomainPk.class)
public class AuthDomain {
	@Id
	@Column(name="auth_id", columnDefinition="varchar(10)")
	private String authId;

	@Id
	@Column(name="menu_id", columnDefinition="varchar(10)")
	private String menuId;
	
	@Column(name="permit", columnDefinition="boolean")
	private Boolean permit;
}

 

AuthDomainPK.java

package com.example.demo.domain.auth;

import java.io.Serializable;

import javax.persistence.Column;
import javax.persistence.Id;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class AuthDomainPk implements Serializable {
	@Id
	@Column(name="auth_id", columnDefinition="varchar(10)")
	private String authId;
	
	@Id
	@Column(name="menu_id", columnDefinition="varchar(10)")
	private String menuId;
}

 

*Repository에서 사용하기 위해 Id를 별도로 선언한 클래스이다.

 

UserDomain.java

package com.example.demo.domain.user;

import java.util.Collection;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.Transient;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Table(name="user_domain")
public class UserDomain implements UserDetails {
	
	@Id
	@Column(name="id", columnDefinition="varchar(10)")
	private String id;

	@Column(name="password", columnDefinition="varchar(10)")
	private String password;
	
	@Column(name="auth_id", columnDefinition="varchar(10)")
	private String authId;

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

	/*
	//lombok의 Getter로 자동생성되니 패스.
	@Override
	public String getPassword() {
		return password;
	}
	*/
	@Override
	public String getUsername() {
		return id;
	}

	@Override
	public boolean isAccountNonExpired() {
		//계정의 만료 여부
		//true : 만료되지 않음
		return true;
	}

	@Override
	public boolean isAccountNonLocked() {
		//계정의 잠김 여부
		//true : 잠금되지 않음
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		//계정의 패스워드 만료 여부
		//true : 만료되지 않음
		return true;
	}

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

}

*참고로 Spring Security의 사용자 정보로 사용하기 위해서는 UserDetails를 상속받아야 한다.

 

여기까지만 만들어놓고 프로젝트를 실행시키고 /login으로 접속 시 다음의 화면이 떠오른다.

이는 기본적으로 Spring Security에서 제공하는 로그인 화면이다.

테스트 용으로는 좋지만, 실제 프로젝트에서 이를 사용할 수는 없으니 컨트롤러를 만들자.

 

 

login.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
	<input type="text" id="username" placeholder="아이디를 입력하세요."> <br>
	<input type="password" id="password" placeholder="비밀번호를 입력하세요."> <br>
	<input type="button" id="loginBtn" value="로그인"> <br>
</body>

<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script th:inline="javascript">
	console.log([[${_csrf}]])
	$("#loginBtn").on('click', function() {
		userName = $("#username").val();
		password = $("#password").val();
		
		if(userName == "") {
			Alert("아이디를 입력하세요.")
			return
		}
		if(password == "") {
			Alert("패스워드를 입력하세요.")
			return
		}
		
		$.ajax({
			type : "POST",
			url : "/login",
			data : {
				[[${_csrf.parameterName}]] : [[${_csrf.token}]], //만약 넣어주지 않으면 에러가 발생한다. Logout도 마찬가지이다.
				username : userName,
				password : password
			},
			success : function(data, state, xhr) {
				console.log(xhr)
				if(xhr.getResponseHeader("isSuccess") == "true") {
					location.replace("/home");
				}
				else if(xhr.getResponseHeader("isSuccess") == "false") {
					//HM 접근 권한이 없을 경우
					alert('접근 권한이 없습니다.');
				}
				else {
					alert('로그인에 실패했습니다. 아이디 혹은 비밀번호를 확인하세요.');
				}
			},
			error : function() {
				
			}
		})
	})
</script>

</html>

원래는 FORM을 사용해야 하지만, Ajax를 사용해보았다.

그리고 Ajax의 특성 상 서버로부터 받는 redirect 신호는 받을 수 없으므로, 직접 location.replace를 활용해 리다이렉트 시켜준다.

 

만드는 김에 홈 화면과 에러 페이지도 만들자.

 

home.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
	<div>사용자 아이디 : <i th:text="${session.id}"></i></div>
	<div>권한 목록 : <i th:text="${session.auth}"></i></div>
	<br>
	<input type="button" id="logOutBtn" value="로그아웃">
</body>


<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script th:inline="javascript">
	$("#logOutBtn").on('click', function() {
		$.ajax({
			type : "POST",
			url : "/logout",
			data : {
				[[${_csrf.parameterName}]] : [[${_csrf.token}]],
			},
			success : function() {
				alert("로그아웃 되었습니다.")
				location.replace("/login")
			}
		})
	})
</script>

</html>

 

403.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
	웹 페이지 접근 권한이 없습니다.
</body>
</html>

 

그 후에는 화면을 뿌려줄 컨트롤러를 만든다.

 

UserController.java

package com.example.demo.controller.user;

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

@Controller
public class UserController {
	@GetMapping("/home")
	public ModelAndView getMainPage() {
		ModelAndView mav = new ModelAndView("home");
		return mav;
	}
	
	@GetMapping("/login")
	public ModelAndView getLoginPage() {
		ModelAndView mav = new ModelAndView("login");
		return mav;
	}
	
	@GetMapping("/403")
	public ModelAndView getErrPage() {
		//연습 페이지에서는 login, home만 존재하고,
		//home 접근 권한이 없을 경우 자동으로 세션이 만료되게 만들어 딱히 사용되진 않는다.
		ModelAndView mav = new ModelAndView("403");
		return mav;
	}
}

 

그리고 Spring Security를 설정할 파일을 만들어야 하는데,

그 전에 앞서 서비스와 리포지토리, 각종 핸들러와 인코더를 만든다.

 

AuthRepository.java

package com.example.demo.repository.auth;

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import com.example.demo.domain.auth.AuthDomain;
import com.example.demo.domain.auth.AuthDomainPk;

@Repository
public interface AuthRepository extends CrudRepository<AuthDomain, AuthDomainPk>{
	@Query
	public Iterable<AuthDomain> findByAuthId(String authId);
	
	@Query
	public Iterable<AuthDomain> findByAuthIdAndPermitTrue(String authId);
}

 

 

UserRespotiroy.java

package com.example.demo.repository.user;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import com.example.demo.domain.user.UserDomain;

@Repository
public interface UserRepository extends CrudRepository<UserDomain, String>{

}

 

UserService.java

package com.example.demo.service.user;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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 com.example.demo.domain.auth.AuthDomain;
import com.example.demo.domain.user.UserDomain;
import com.example.demo.repository.auth.AuthRepository;
import com.example.demo.repository.user.UserRepository;

@Service
public class UserService implements UserDetailsService {

	@Autowired private UserRepository userRepo;
	@Autowired private AuthRepository authRepo;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		UserDomain userDomain = userRepo.findById(username).orElseThrow(() -> new UsernameNotFoundException(username));
		
		//권한 리스트 넣어주고 반환
		try {
			Collection<SimpleGrantedAuthority> authList = new ArrayList<>();
			Iterator<AuthDomain> authItr = authRepo.findByAuthIdAndPermitTrue(userDomain.getAuthId()).iterator();
			
			while(authItr.hasNext()) {
				authList.add(new SimpleGrantedAuthority(authItr.next().getMenuId()));
			}
			
			userDomain.setAuthorities(authList);
		}
		catch (Exception e) { e.printStackTrace(); }
		
		return userDomain;
	}

}

*서비스는 기본적으로 UserDetailsService를 상속하여 만들어야 한다.

*loadUserByUsername 함수를 상속받아, username을 기반으로 해당 사용자를 찾아 리턴해줘야 한다.

 

 

LoginSuccessHandler.java

package com.example.demo.config;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import com.example.demo.domain.user.UserDomain;

public class LoginSuccessHandler implements AuthenticationSuccessHandler {

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		HttpSession session = request.getSession();
		
		session.setAttribute("id", authentication.getName()); //name을 가져와 세션에 저장한다.
		//만약 다른 속성값을 가져오고 싶다면 아래와 같이 가져온다.
		/*
		session.setAttribute("name", ((UserDomain) authentication.getPrincipal()).getName());
		 */
		
		Object[] authObject = authentication.getAuthorities().toArray();
		List<String> authList = new ArrayList<>();
		for(Object auth : authObject) {
			authList.add(auth.toString());
		}
		session.setAttribute("auth", authList);
		
		//홈 (메인 페이지) 입장 권한이 없을 경우 세션을 삭제하고 헤더에 해당 내용을 지정하여 뷰에 알려준다.
		if(!authList.contains("HM")) {
			response.setHeader("isSuccess", "false");
			SecurityContextHolder.clearContext();
		}
		else {
			response.setHeader("isSuccess", "true");
		}
		
		

	}

}

*세션에 사용자 정보를 입력하기 위해 만든 핸들러이다.

 

 

NoPasswordEncoder.java

package com.example.demo.config;

import org.springframework.security.crypto.password.PasswordEncoder;

public class NoPasswordEncoder implements PasswordEncoder {

	@Override
	public String encode(CharSequence rawPassword) {
		return rawPassword.toString();
	}

	@Override
	public boolean matches(CharSequence rawPassword, String encodedPassword) {
		return rawPassword.toString().equals(encodedPassword);
	}

}

*원래는 패스워드를 암호화할 인코더를 설정해주어야 하는데, 현재 실습에서는 회원가입 없이 DB에 직접 입력하기 위해 커스텀 인코더를 만들었다.

 

마지막으로 스프링 시큐리티의 설정 파일을 만든다.

 

SecurityConfig.java

package com.example.demo.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import com.example.demo.service.user.UserService;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	@Autowired private UserService userService;

	@Bean
	public PasswordEncoder passwordEncoder() {
		return new NoPasswordEncoder();
	}

	@Bean
	public SessionRegistry sessionRegistry() {
		return new SessionRegistryImpl();
	}
	
	@Bean
	public AuthenticationSuccessHandler loginSuccessHandler() {
		return new LoginSuccessHandler();
	}

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		//사용할 서비스와 패스워드 인코더를 설정한다.
		//굳이 회원가입 페이지를 별도로 만들지 않을 예정이므로 아무런 인코딩도 하지 않는 별도의 인코더를 별도로 선언하여 사용한다.
		auth
			.userDetailsService(userService)
				.passwordEncoder(passwordEncoder());
	}

	@Override
	public void configure(WebSecurity web) throws Exception {
		//기본적으로 모든 url을 차단하는데, 그 중 제외할 것들을 선언한다.
		//대부분 js와 css, font 등의 기능들을 여기서 해제하여 사용한다.
		web
			.ignoring()
				.antMatchers("/css/**", "/js/**", "/fonts/**");
		//여기서 **는 몇 차례의 depth도 허용함을 의미한다.
		//만약 *만 선언 시,
		// /css/a.css 는 허용되지만
		// /css/css1/a.css 는 허용되지 않는다.
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests()
				.antMatchers("/login").permitAll() 					//login 페이지는 누구든 접근할 수 있다.
				.antMatchers("/logout").authenticated() 			//logout 페이지는 인증된 사용자만 접근이 가능하다.
				.antMatchers("/home").hasAuthority("HM") 			//HM이라는 권한을 가진 사용자만 접근이 가능하다.
			  //.antMatchers("/home").hasAnyAuthority("A", "B") 	//A, B 중 어느 권한이라도 갖고 있다면 접근 가능.
				.anyRequest().authenticated()						//이외의 접근은 모두 인증된 상태여야 한다.
			.and()
				.formLogin()
					.loginPage("/login") 					//사용자가 Login 페이지를 직접 지정한다.
					.successHandler(loginSuccessHandler())	//로그인 성공 시 핸들러를 지정한다.
			.and()
				.logout()
					.logoutSuccessUrl("/login")		//로그아웃 성공 시 로그인 페이지로 이동
					.invalidateHttpSession(true) 	//세션 삭제
					.deleteCookies("JSESSIONID") 	//쿠키 삭제
					.clearAuthentication(true)		
			.and()
				.exceptionHandling()
					.accessDeniedPage("/error/403") //인증 없이 진입하거나 해당 웹 페이지 권한이 없을 경우
			.and()
				.sessionManagement()
					.maximumSessions(1) 			//인당 1 세션 허용
					.maxSessionsPreventsLogin(true)	//중복 로그인 시도 시 로그인 자체를 차단한다. false 시 이전 세션을 강제로 만료시킨다고 한다.
					.expiredUrl("/login") 			//세션 만료 시 login 페이지로 이동.
					.sessionRegistry(sessionRegistry());
	}
	
	
}

*만약 csrf를 사용하고 싶지 않다면 http.csrf().disable()를 선언한다.

 

 

여기까지만 하고 돌려보자.

데이터는 아래와 같다.

 

없는 아이디 혹은 잘못된 패스워드
홈 접근권한이 없는 사용자
로그인 성공

728x90
반응형

'실습 > 리눅스 서버 + 스프링 부트' 카테고리의 다른 글

Scheduler  (0) 2022.01.15
JPA  (0) 2021.11.27
HAProxy를 활용한 IFrame 활성화  (0) 2021.09.26
[스프링부트] Thymeleaf  (0) 2021.09.26
09. MariaDB CRUD_UI  (0) 2021.09.18