티스토리 뷰

오늘 짬 시간이 남아 마지막 스프링 부트 시큐리티를 포스팅하려고 합니다.

이전 포스팅에서 개념과 프로젝트 기반을 만들었고 오늘은 빠르게 시큐리티를 커스터마이징 하겠습니다.

 

https://hyunsangwon93.tistory.com/26

 

스프링 부트 시큐리티(spring boot security) 시작 [2]

복습 지난 https://hyunsangwon93.tistory.com/24 포스팅에서 시큐리티의 간단한 개념과 인증 순서를 배웠다. 스프링 부트 시큐리티(spring boot security) 시작 [1] 서론 Spring Security는 Spring으로 웹을 개발..

hyunsangwon93.tistory.com

 시큐리티 커스텀 마이징 UserDetailsService 인터페이스 구현하기

UserDetailsService를 상속받으면 loadUserByUsername(String) 메소드를 오버라이딩 해야합니다.

이 메소드에서 DB로부터 회원정보를 가져와 있는 회원인지 아닌지 체크여부를 하는 중요한 메소드 입니다.

 

@Service
public class HomeService implements UserDetailsService{

	@Autowired
	private HomeMapper homeMapper;
    
    @Override
	public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
		
        //DB로부터 회원 정보를 가져온다.
		ArrayList<UserVO> userAuthes = homeMapper.findByUserId(id);
		
		if(userAuthes.size() == 0) {
			throw new UsernameNotFoundException("User "+id+" Not Found!");
		}
		
		return new UserPrincipalVO(userAuthes); //UserDetails 클래스를 상속받은 UserPrincipalVO 리턴한다.
	}
}

 

아직 mapper에 쿼리 메소드를 만들지 않았습니다. 

loadUserByUsername 메소드 매개변수로 String 타입을 받아오는데, 이값이 로그인 창에서 사용자가 작성한 아이디 혹은 이메일 입니다. AuthenticationProvider 로부터 받아온 값입니다. UserDetails를 상속받은 UserPrincipalVO를 다시  AuthenticationProvider로 return이 됩니다.

회원 정보를 List 형태로 받아오는 이유는 유저당 권한이 여러개일 수 도있기 때문입니다. 

 

Mapper 인터페이스 작성
package com.example.demo.mapper;

import java.util.ArrayList;

import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;

import com.example.demo.vo.UserVO;

@Repository
public interface HomeMapper {

	//유저 정보
	ArrayList<UserVO> findByUserId(@Param("id") String id);

	//유저 저장
	int userSave(UserVO userVO);

	//유저 권한 저장
	int userRoleSave(@Param("userNo") int userNo,@Param("roleNo") int roleNo);

	//유저 FK번호 알아내기
	int findUserNo(@Param("id") String id);

	//권한 FK번호 알아내기
	int findRoleNo(@Param("roleName") String roleName);
}

총5개의 메소드가 필요할 예정입니다. 테이블이 3개이기 때문이죠. 혹시나 이부분에서 다른방법이 있으시면 댓글 감사하겠습니다.

 

Query 문 작성
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.HomeMapper">

	<select id="findByUserId" resultType="com.example.demo.vo.UserVO">
		SELECT
			u.password AS password,
			u.name AS name,
			r.role_name AS roleName
		FROM user AS u
		  INNER JOIN user_role AS ur
		  ON u.user_no = ur.user_no
		  INNER JOIN role AS r 
		  ON r.role_no = ur.role_no
		WHERE
			u.id = #{id}
    </select>
	
	<insert id="userSave" parameterType="com.example.demo.vo.UserVO">
	    INSERT INTO user
        (
            id,
            password,
            name
        )
        VALUES
        (
            #{id},
            #{password},
            #{name}
        )
	</insert>

	<insert id="userRoleSave">
		INSERT INTO user_role
        (
            user_no,
            role_no
        )
        VALUES
        (
            #{userNo},
            #{roleNo}
        )
	</insert>
	
	<select id="findUserNo" resultType="Integer">
		SELECT 
			user_no
		FROM user
		WHERE id = #{id}
	</select>
	
	<select id="findRoleNo" resultType="Integer">
		SELECT 
			role_no
		FROM role
		WHERE role_name = #{roleName}
	</select>
	
</mapper>

 

VO 객체 설정

UserVO 작성

package com.example.demo.vo;

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

@Getter
@Setter
@ToString
public class UserVO {

	private int userNo; //회원 pk
	private String id; //회원 아이디
	private String password;// 비밀번호
	private String name; // 회원 이름
	
	private String roleName; //권한 이름
	
}

UserPrincipalVO 작성

 

userDeatails 인터페이스를 꼭 구현해야합니다. 이유는 인증순서에도 나타나듯이 loadUserByUsername 메소드가 userDeatails를 리턴하기 때문입니다. 총7개의 메소드를 오버라이딩 해야하며  각 메소드마다 특징이 있습니다. 가장 중요한 메소드는 getAuthorities() 입니다.

이 메소드를 통해 한 계정에 권한을 몇개 가졌는지 확인이 가능하기 때문입니다. 

나머지 상속받은 메소드 값들은 DB에 컬럼으로 만들어 관리하셔도 무관합니다.

package com.example.demo.vo;

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

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

public class UserPrincipalVO implements UserDetails{
	
	private ArrayList<UserVO> userVO;
	
	public UserPrincipalVO(ArrayList<UserVO> userAuthes) {
		this.userVO = userAuthes;
	}
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() { //유저가 갖고 있는 권한 목록

		List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
		
		for(int x=0; x<userVO.size(); x++) {
			authorities.add(new SimpleGrantedAuthority(userVO.get(x).getRoleName()));
		}
		
		return authorities;
	}

	@Override
	public String getPassword() { //유저 비밀번호

		return userVO.get(0).getPassword();
	}

	@Override
	public String getUsername() {// 유저 이름 혹은 아이디

		return userVO.get(0).getName();
	}

	@Override
	public boolean isAccountNonExpired() {// 유저 아이디가 만료 되었는지

		return true;
	}

	@Override
	public boolean isAccountNonLocked() { // 유저 아이디가 Lock 걸렸는지

		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() { //비밀번호가 만료 되었는지

		return true;
	}

	@Override
	public boolean isEnabled() { // 계정이 활성화 되었는지

		return true;
	}

	
}

 

WebConfig class에 비밀번호 암호화 클래스 빈등록
package com.example.demo.config;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
@MapperScan(value= {"com.example.demo.mapper"})
public class WebConfig {
	
	@Bean
	public BCryptPasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
}

@Bean으로 등록한 BCryptPasswordEncoder 클래스는 암호화가 필요한 클래스에 계속 재사용하면 되겠습니다. 여기서는 HomeService와 있다가 구현할 SecurityConfig 클래스에서 필요하게 됩니다. 

빈으로 사용안하고 바로 new로 호출해도 사용해도 상관없습니다 :)

 

회원 가입할 RestController 작성
package com.example.demo.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.service.HomeService;
import com.example.demo.vo.UserVO;

@RestController
public class UserRestController {

	@Autowired
	private HomeService homeService;
	
	@PostMapping("/user/save")
	public String saveUserInfo(@RequestBody UserVO userVO) {
		return homeService.InsertUser(userVO);
	}
	
	
}

 

HomeService 회원 가입 메소드 구현

회원 가입 메소드까지 구현한 최종 HomeService 입니다. InsertUset 메소드에 여러 트랜잭션이 발생하기 때문에, @Transactional 으로 롤백을 지정 해줍니다.

package com.example.demo.service;

import java.util.ArrayList;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import com.example.demo.mapper.HomeMapper;
import com.example.demo.vo.UserPrincipalVO;
import com.example.demo.vo.UserVO;

@Service
public class HomeService implements UserDetailsService{

	@Autowired
	private HomeMapper homeMapper;
	
	@Autowired
	private BCryptPasswordEncoder bCryptPasswordEncoder;
	
	/* DB에서 유저정보를 불러온다.
	 * Custom한 Userdetails 클래스를 리턴 해주면 된다.
	 * */
	@Override
	public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
		
		ArrayList<UserVO> userAuthes = homeMapper.findByUserId(id);
		
		if(userAuthes.size() == 0) {
			throw new UsernameNotFoundException("User "+id+" Not Found!");
		}
		
		return new UserPrincipalVO(userAuthes);
	}
	
	@Transactional(propagation = Propagation.REQUIRED, rollbackFor = {Exception.class})
	public String InsertUser(UserVO userVO) {
		
		userVO.setPassword(bCryptPasswordEncoder.encode(userVO.getPassword()));
		int flag = homeMapper.userSave(userVO);		
		if (flag > 0) {

			int userNo = homeMapper.findUserNo(userVO.getId());
			int roleNo = homeMapper.findRoleNo(userVO.getRoleName());

			homeMapper.userRoleSave(userNo, roleNo);

			return "success";
		}	 	
		return "fail";
	}

}

 

SecurityConfig 작성

가장 핵심적인 부분이기 때문에 메소드 별로 나누고 마지막엔 통합 소스를 보여 드리겠습니다.

 

우선 AuthenticationProvider 가 UserDeatils 에게 유저로 부터 받은 아이디 값을 넘겨 줘야겠죠? 

인증 순서를 잊으시면 안됩니다. 필자는 DaoAuthenticationProvider를 사용했습니다. DaoAuthenticationProviderUserDetails를 구현한 클래스를 setUserDetailsService() 파라미터 값으로 받습니다. 그리고 내부적으로 loadUserByUsername 메소드를 처리 합니다. 

HomeService에 UserDeatails를 구현했으니 값을 넘겨주고 암호화는BCryptPasswordEncoder 를 사용했기

때문에 이역시 값을 지정해줍니다. 그러면 인증 절차는 끝입니다. 

(이부분에서 datasource를 사용해 여기서 쿼리를 사용해서 인증하는 개발자도 있습니다. 필자는 쿼리는 쿼리에서(xml) 처리하는게 더 보기 좋다고 생각합니다.)

	@Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;
	@Autowired
	private HomeService homeService;
	
	@Bean
    public DaoAuthenticationProvider authenticationProvider(HomeService homeService) {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(homeService);
        authenticationProvider.setPasswordEncoder(bCryptPasswordEncoder);
        return authenticationProvider;
    }

	@Override
	protected void configure(AuthenticationManagerBuilder auth) {
		  auth.authenticationProvider(authenticationProvider(homeService));
	}

 

마지막으로 HTTP 관련 인증,보안 설정하면 시큐리는 끝입니다. 

여기서 URL 별로 권한을 부여하고, 로그인,로그아웃, 세션, 보안을 설정할 수 있습니다.

 

중요한 메소드를 정리 하겠습니다.

 

permitAll() : 모두 접속 허락 하겠다. 

hasAnyAuthority() : 해당 권한을 가진 유저만 접속 가능하다.

anyRequest().authenticated() : 나머지 URL은 인증을 걸쳐야 한다. 

.csrf().ignoringAntMatchers("/user/save") : CSRF 토큰 없이 실행 하겠다. 

defaultSuccessUrl("/") : 로그인이 성공 되면 해당 URL로 이동하겠다.

logoutRequestMatcher(new AntPathRequestMatcher("/logout")) : 해당 /logout 를 받으면 로그아웃.
.deleteCookies("JSESSIONID") : JSESSIONID를 지우겠다.

accessDeniedPage("/access-denied") : 권한이 없는 URL에 접속하려고 하면 해당 URL로 리다이렉션.

@Override
	protected void configure(HttpSecurity http) throws Exception{
		http
			.authorizeRequests()
				.antMatchers("/user/save").permitAll()
				.antMatchers("/").hasAnyAuthority("ADMIN","USER")
				.anyRequest().authenticated()
			.and()
				.csrf().ignoringAntMatchers("/user/save")
			.and()
				.formLogin()
				.defaultSuccessUrl("/")
			.and()
				.logout()
				.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
				.deleteCookies("JSESSIONID")
			.and()
				.exceptionHandling()
				.accessDeniedPage("/access-denied");
	}

 

마지막 Controller, html 
package com.example.demo.controller;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.GetMapping;

import com.example.demo.vo.UserPrincipalVO;

@Controller
public class HomeController {

	@GetMapping("/")
	public String loadExceptionPage(ModelMap model) throws Exception{
		
		Authentication auth = SecurityContextHolder.getContext().getAuthentication();
		UserPrincipalVO userPrincipalVO = (UserPrincipalVO) auth.getPrincipal();
		
		model.addAttribute("name",userPrincipalVO.getUsername());
		model.addAttribute("auth",userPrincipalVO.getAuthorities());
		
		return "index";
	}
	
	@GetMapping("/access-denied")
	public String loadAccessdeniedPage() throws Exception{
		return "index";
	}
	
}

리소스 바로 아래 경로에

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

<head>
    <title>Admin Page</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
</head>

<body>
    <div class="container">
        <form th:action="@{/logout}" method="post"> 
        <button class="btn btn-md btn-danger btn-block" name="registration"type="Submit">Logout</button> 
        </form>
        <div class="panel-group" style="margin-top:40px">
            <div class="panel panel-primary">
                <div class="panel-heading"> <span th:utext="${name}"></span> </div>
                <p class="admin-message-text text-center" th:utext="${auth}"></p>
            </div>
        </div>
    </div>
</body>
</html>

 

이 상태로 쭈욱하시면 이상없이 돌아갑니다. 나머지는 어플리케이션이 맞게 추가적으로 개발자들이 맞춰 주시면 됩니다.