본문 바로가기
국비학원/수업기록

국비 지원 개발자 과정_Day103

by 루팽 2023. 4. 26.

스프링 시큐리티 개요

1. spring-security.jar을 추가했다

2. 모든 페이지에 접근이 불가능해졌다

3. 권한이 있어야 페이지 출력이 가능하다

4. 내가 로그인 화면을 구성하지 않았음에도 로그인 화면으로 유도된다

5. FilterChain 제공된다

6. SecurityConfig.java 추가

  -> 더 이상 스프링에서 제공하는 페이지로 가지 않아도 된다

7. 사용자 정의 시큐리티 코딩 전개하기

  -> 스프링 시큐리티는 스프링 시큐리티 세션을 들고 있다

  서버 세션 영역 안에 시큐리티가 관리하는 세션이 따로 존재함

  HttpSession session =request.getSession() - 기존의 세션

 

  시큐리티 세션에는 무조건 Authentication 객체만 들어갈 수 있다

  Authentication이 시큐리티 세션 안에 들어있다는 것은 로그인된 상태라는 의미임

  Authentication에는 2개의 타입이 들어감

  UserDetails -> PrincipalUserDetails

  OAuth2User

 

문제제기

세션이 두 개 타입이라서 컨트롤러에서 처리가 복잡해짐 일반 개발자 정의 로그인에서는 UserDetails타입으로 Authentication 객체가 만들어지고 구글로그인처럼 OAuth로그인 시에는 OAuth2User타입으로 Authentication객체가 생성됨

 

해결방법

PrincipalDetails에 UserDetails, OAuth2User를 implements 한다

 

<스프링 시큐리티 복습 완료 - HomeController.java>

package com.example.demo.controller;

import javax.servlet.http.HttpSession;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.example.demo.auth.PrincipalDetails;
import com.example.demo.logic.UserLogic;
import com.example.demo.model.User;

@Controller
public class HomeController {
	Logger logger = LogManager.getLogger(HomeController.class);
	
	@Autowired
	private UserLogic userLogic = null;
	
	@Autowired
	public BCryptPasswordEncoder bCryptPasswordEncoder = null;
	
	// user-123 admin-123
	@GetMapping("/")
	public String index(Authentication authentication, HttpSession session) {
		logger.info("index");
		PrincipalDetails principalDetails = null;
		if(authentication !=null) {
			principalDetails = (PrincipalDetails)authentication.getPrincipal();
			logger.info(principalDetails.getUser());	
			logger.info(principalDetails.getUser().getId());	
			logger.info(principalDetails.getUser().getEmail());	
			logger.info(principalDetails.getUser().getCreateDate());	
			logger.info(principalDetails.getUser().getUsername());	
			logger.info(principalDetails.getUser().getPassword());	
			logger.info(principalDetails.getUser().getRole());	
			session.setAttribute("email", principalDetails.getUser().getEmail());
			session.setAttribute("role", principalDetails.getUser().getRole());
		}
		return "forward:index.jsp";
	}
	
	// 로그인 화면
	@GetMapping("/loginForm")
	public String loginForm() {
		logger.info("loginForm");
		return "forward:loginForm.jsp";
	}

	// 로그인 에러 화면
	@GetMapping("/login-error")
	public String loginError() {
		logger.info("loginError");
		return "forward:loginError.jsp";
	}
	
	// 회원가입 화면
	@GetMapping("/joinForm")
	public String joinForm() {
		logger.info("joinForm");
		return "forward:joinForm.jsp";
	}
	
	// 회원가입
	@PostMapping("/join")
	public String join(User user) {
		logger.info("join");
		int result = 0; // 등록 성공 유무 담기
		user.setRole("ROLE_USER");
		// 패스워드 암호화처리
		String rawPassword = user.getPassword();
		String encPassword = bCryptPasswordEncoder.encode(rawPassword);
		user.setPassword(encPassword);
		result = userLogic.memberInsert(user);
		return "forward:loginForm.jsp";
	}
	
	// 유저 화면
	@GetMapping("/user")
	public String userPage() {
		logger.info("userPage");
		return "forward:user-index.jsp";
	}
	
	// 관리자 화면
	@GetMapping("/admin")
	public String adminPage() {
		logger.info("adminPage");
		return "forward:admin-index.jsp";
	}
	
	// @ResponseBody주면 text로 출력됨
	@GetMapping("/auth")
	public @ResponseBody Authentication auth() {
		return SecurityContextHolder.getContext().getAuthentication();
	}
}

 

<스프링 시큐리티 복습 완료 - SecurityConfig.java>

package com.example.demo.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

//import com.example.demo.oauth.PrincipalOauth2UserService;

// 1-1에서틑 SecurityConfig 파일을 생성하지 않았다 -> 스프링시큐리티에서 제공하는 인증화면으로 처리 진행됨
// 이 클래스를 정의하지 않으면 스프링 시큐리티가 제공하는 DefaultLoginPageGeneratingFilter가
// 제어권을 가져가서 개발자가 만든 로그인 화면을 만날 수 없다
// 결론: 내가 주도하는 인증처리를 위해서는 반드시 선언해야함
@EnableWebSecurity(debug=true) // 요청이 지나가는 필터정보 확인 가능
@EnableGlobalMethodSecurity(securedEnabled=true ,prePostEnabled=true) // 권한을 체크하겠다는 설정 추가
public class SecurityConfig {
	// 암호화가 안된 비밀번호로는 로그인이 안됨
	// 패스워드를 암호화하기위한 코드 추가
	// Bean어노테이션을 적으면 해당 메소드의 리턴되는 오브젝트를 IoC로 등록해줌
//	@Autowired
//	private PrincipalOauth2UserService principalOauth2UserService;
	
	@Bean
	public BCryptPasswordEncoder encodePwd() {
		return new BCryptPasswordEncoder();
	}

	// 상수로 처리시에 주석을 반드시 풀 것
	/*
	 @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder();
	 }
	 */
	
	// 만약 관리자 계정이면 하위인 유저 계정에 접근할 수 있도록 처리하려면 아래 코드를 추가함
	// 유저는 관리자에 접근이 안되지만 관리자는 유저페이지도, 관리자페이지 접근 가늘하도록 설정
	@Bean
	RoleHierarchy roleHierarchy() {
		RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
		roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
		return roleHierarchy;
	}
	
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
		AuthenticationManagerBuilder auth = 
				http.getSharedObject(AuthenticationManagerBuilder.class);
		/*		
		auth.inMemoryAuthentication()
		// 아래 메소드가 데프리케이트 대상인 이유는 테스트용도로만 사용하라는 경고의 의미
		// 보안상 안전하지 않으니 쓰지말것을 당부 -  그러나 지원은 끊지 않음
 		.withUser(User.withDefaultPasswordEncoder()
				.username("user")
				.password("123")
				.roles("USER")
		).withUser(User.withDefaultPasswordEncoder()
				.username("admin")
				.password("123")
				.roles("ADMIN")
		);
		*/
		http.csrf().disable(); // csrf filter 비활성화하기
		http.authorizeRequests() // http요청으로 들어오는 모든 것에대해서 매칭하기
		// localhost:5000/user로 요청하면 403발동 - 403접근권한이 없을때
		.antMatchers("/user/**").authenticated() // 인증만 되면 들어갈 수 있는 주소
		// manager나 admin 권한이 있는 사람만 접근가능, securedEnabled=true속성추가해야함
		.antMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")
		// admin 권한이 있는 사람만 접근가능
		.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
		.anyRequest().permitAll() // 위 세가지가 아닌 경우는 모두 허용함
		.and()
		.formLogin()
		.loginPage("/loginForm") // 2차 단위테스트 - 권한에따라서 페이지 제어
		.loginProcessingUrl("/login") // 이 URL이 요청되면 시큐리티가 인터셉트해서 대신 로그인 진행
		.failureUrl("/login-error")
		.defaultSuccessUrl("/")
		.and()
		// 구글 로그인 기능 구현 추가
		.oauth2Login()
		.loginPage("/loginForm");
		// 구글 로그인 후 후처리 필요함
		// 1. 인증받기 -> 2. 엑세스 토큰(권한) -> 3. 사용자 프로필정보 가져오기 -> 4. 그 정보로 회원가입 처리가능(여기선 미적용)
		// 구글 로그인이 성공되면 코드를 받는게 아니라 엑세스 토큰과 사용자에대한 프로필정보를 한번에 받아옴
//		.userInfoEndpoint()
//		.userService(principalOauth2UserService); // 구글 로그인 후처리 클래스 추가
		// 현재 페이지에서 로그아웃을 눌럿을 때 로그인 페이지가 아니라 메인 페이지에 있도록 해줌
		//.logout(logout -> logout.logoutSuccessUrl("/"))
		// 403번 접근제한 예외 발생시 이동할 페이지 요청URL 작성하기
		//.exceptionHandling(exception -> exception.accessDeniedPage("/access-denied"));
		
		return http.build();
	}// end of filterChain
}

/*
	테스트 시나리오
	localhost:5000 요청은 권한과 상관없이 열림
	localhost:5000/user
	localhost:5000/admin
	localhost:5000/manager
	로그인 페이지로 이동함
	
	첫번째 - user롤일때
	localhost:5000/user 출력되고
	localhost:5000/admin 403발동
	localhost:5000/manager 403발동
	
	두번째 - admin 로그인했을때
	lcalhost:5000/user 출력
	localhost:5000/admin 출력
	localhost:5000/manager 출력
	
	두번째 - manager 로그인했을때
	lcalhost:5000/user 403발동
	localhost:5000/admin 403발동
	localhost:5000/manager 출력
*/

 

<스프링 시큐리티 복습 완료 - PrincipalDetails.java>

package com.example.demo.auth;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

import com.example.demo.model.User;

import lombok.Data;

//여기에서 쥔 값을 세션에 담아야 함
@Data
public class PrincipalDetails implements UserDetails, OAuth2User {
	private static final long serialVersionUID = 1L;
	private User user;
	// 구글 로그인시 구글 서버에서 넣어주는 정보가 Map형태인데 그것을 받을 변수 선언
	private Map<String, Object> attributes;
	
	//일반 로그인시 사용하는 생성자
	public PrincipalDetails(User user) {
		this.user = user;
	}
	
	//OAuth로그인시 사용하는 생성자
	// attributes를 통해서 User를 생성해준다
	// 2번째 파라미터 자리는 구글 인증 정보를 담음
	public PrincipalDetails(User user, Map<String, Object> attributes) {
		this.user = user;
		this.attributes = attributes;
	}
	
	//해당 User권한을 리턴하는 곳
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> collect = new ArrayList<>();
		collect.add(new GrantedAuthority() {
			@Override
			public String getAuthority() {
				return user.getRole();
			}
		});
		return collect;
	}
	
	//데이터베이스와 매칭이 안되면 로그인 실패 페이지 호출됨
	@Override
	public String getPassword() {
		return user.getPassword();
	}

	@Override
	public String getUsername() {
		return user.getUsername();
	}
	
	//강제는 아니나 사용자가 세션에 담고 싶은 다른 컬럼정보도 등록이 가능함
	public String getRole() {
		return user.getRole();
	}
	
	public String getCreateDate() {
		return user.getCreateDate();
	}

	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	@Override
	public boolean isEnabled() {
		return true;
	}

	@Override
	public Map<String, Object> getAttributes() {
		return attributes;
	}
	
	@Override
	public String getName() {
		return null;
	}
}

 

<스프링 시큐리티 복습 완료 - PrincipalOauth2UserService.java>

package com.example.demo.oauth;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import com.example.demo.auth.PrincipalDetails;
import com.example.demo.dao.UserDao;
import com.example.demo.model.User;

//구글로그인버튼 -> 구글로그인창 -> 로그인을 완료 -> 코드를 리턴
//AccessToken요청 -> loadUser호출 -> 구글 프로필정보 받음

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
	Logger logger = LogManager.getLogger(PrincipalOauth2UserService.class);
	
	@Autowired
	private UserDao userDao;
	
	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		OAuth2User oAuth2User = super.loadUser(userRequest);
		String provider = userRequest.getClientRegistration().getRegistrationId();
		String providerId = oAuth2User.getAttribute("sub");
		String email = oAuth2User.getAttribute("email");
		String role = "ROLE_USER";
		logger.info(userRequest);
		logger.info("구글발급해준 토큰 : " + userRequest.getAccessToken().getTokenValue());
		logger.info("provider : " + userRequest.getClientRegistration().getRegistrationId());
		logger.info("providerId : " + oAuth2User.getAttribute("sub"));
		String username = provider + "_" + providerId;
		User user = userDao.login(username); // 회원가입이 되었는지 유무 체크하기위해 추가함
		// 구글 로그인이 처음인 경우
		if(user == null) {
			user = User.builder()
					.username(username)
					.password("123")
					.email(email)
					.role(role)
					.provider(provider)
					.providerId(providerId)
					.build();
			userDao.memberInsert(user);
		}
		return new PrincipalDetails(user, oAuth2User.getAttributes());
	}
}

 

<스프링 시큐리티 복습 완료 - User.java>

package com.example.demo.model;

import java.sql.Timestamp;

import lombok.Builder;
import lombok.Data;

@Data // getter, setter생성해줌
public class User {
	private int id;
	private String username;
	private String password;
	private String email;
	private String role; // ROLE_USER, ROLE_MANAGER, ROLE_ADMIN
	private String createDate;
	private String provider;
	private String providerId;
	
	// 회원가입에 사용할 생성자 추가
	// @Builder하면 안됨! - 들고온 정보를 모두 초기화하기에! 문법상 써놓은 생성자임!
	public User() {}
	
	// UserRepository의 findByUsername으로 찾아낸 정보로 초기화가됨
	@Builder
	public User(String username, String password, String email
			, String role, String createDate, String provider, String providerId) {
		this.username = username;
		this.password = password;
		this.email = email;
		this.role = role;
		this.createDate = createDate;
		this.provider = provider;
		this.providerId = providerId;
	}
}

 

<스프링 시큐리티 복습 완료 - member.xml>

<?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">
	<!-- 회원 정보 입력 -->
	<insert id="memberInsert" parameterType="map">
		INSERT INTO member202210(
						  id
						, username
						, password
						, email
						, role
						, createDate
						, provider
						, providerId
						)
		VALUES (SEQ_MEMBER202210_ID.nextval
					<if test="username != null">
						, #{username}
					</if>
					<if test="password != null">
						, #{password}
					</if>
					<if test="email != null">
						, #{email}
					</if>
					<if test="role != null">
						, #{role}
					</if>
						, to_char(sysdate, 'YYYY-MM-DD')
					<if test="provider != null">
						, #{provider}
					</if>
					<if test="providerId != null">
						, #{providerId}
					</if>
					)
	</insert>

	<!-- 로그인 -->
	<select id="login" parameterType="string" resultType="com.example.demo.model.User">
		SELECT id, username, password, role, email, createdate
		  FROM member202210
		 WHERE username = #{value}
	</select>
	
	<!-- 목록 출력 -->
	<select id="memberList" parameterType="map" resultType="map">
		select mem_uid, mem_name, mem_email 
		      ,mem_no, mem_nickname, mem_status, mem_auth
		  from member230324
		<where>
			<if test='mem_uid!=null and mem_uid.length()>0'>
				AND mem_uid = #{mem_uid}
			</if>
	<!-- 
	<input type=text id="mem_nickname" value=""/>
	항상 무조건 빈문자열이다. 폼전송하면 무조건 빈문자열이 있는 상태이다 
	너가 아무것도 입력하지 않아도  null에 걸리지 않는다 
	잡아내려면 문자열이 > 0 까지를 비교해야 잡아낼수 있다 
	 -->		
			<if test='MEM_NICKNAME!=null and MEM_NICKNAME.length()>0'>
				AND mem_nickname = #{MEM_NICKNAME}
			</if>
			<if test='mem_name!=null and mem_name.length()>0'>
				AND mem_name = #{mem_name}
			</if>
			<if test='mem_tel!=null and mem_tel.length()>0'>
				AND mem_tel = #{mem_tel}
			</if>
		</where>
	</select>
</mapper>

 

<스프링 시큐리티 복습 완료 - index.jsp>

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>JSP</title>
	<%@ include file="../common/bootstrap_common.jsp" %>
	<link rel="stylesheet" type="text/css" href="/css/common.css">
	 <script src="https://developers.kakao.com/sdk/js/kakao.min.js"></script>
	 <script
	  type="text/javascript"
	  src="//dapi.kakao.com/v2/maps/sdk.js?appkey=e83164cf4f1a17d846fcb5c4a6c4c365"></script>
	  <script type="text/javascript">
	  	let map;
	  	let message;
	  	let container;
	  	const positions = [
	  		{
	  			content: '<div>터짐블로그</div>',
		      latlng: new kakao.maps.LatLng(33.450701, 126.570667)
	  		}
	  	]
			// 현재 위치 버튼 클릭시 호출되는 메소드
		  	const geoloc = () => {
		  		if(navigator.geolocation) {
		  			navigator.geolocation.getCurrentPosition(function(pos) {
		  				const lat = pos.coords.latitude; // 위도
		  				const lon = pos.coords.longitude; // 경도
		  				const locPosition = new kakao.maps.LatLng(lat, lon);
		  				map = new kakao.maps.Map(container, options);
		  				message = '<div style="padding: 5px">현재위치</div>';
		  				const marker = new kakao.maps.Marker({
		  					map: map,
		  					position: locPosition,
		  				})
		  				const iwContent = message, iwRemoveable = true;
		  				const infowindow = new kakao.maps.InfoWindow({
		  					content: iwContent,
		  					removeable: iwRemoveable
		  				})
		  				infowindow.open(map, marker)
		  				map.setCenter(locPosition);
		  			})
		  		} else {
		  			console.log('해당 브라우저는 지원하지 않습니다.')
		  		}
		  	}
	  </script>
</head>
<body>
	<div class='divContainer'>
		<%@ include file="../include/header.jsp" %>
	</div>
	<div class='divHeader'>
	</div>
	<div class='divForm'>
		<h2>이벤트존</h2>
		<hr style="height: 2px" />
		<h2>추천상품존</h2>
		<hr style="height: 2px" />
		<div class='divMapParent'>
			<div id="map" class='divMap' style="width:500px;height:400px;"></div>
			<div style="margin-bottom: 5px"></div>
			<input class='btnGeoLoc' type="button" onclick="geoloc()" value='현재위치'>
			<script type="text/javascript">
				container = document.querySelector("#map");
				const options = {
						center: positions[0].latlng,
						level: 3
				}
				map = new kakao.maps.Map(container, options)
				for(let i=0; i<positions.length; i++) {
					// 마커를 생성하기
					const marker = new kakao.maps.Marker({
						map: map, // 마커를 표시할 지도
						position: positions[i].latlng
					})
					// 마커에 표시할 infowindow 생성
					const infowindow = new kakao.maps.InfoWindow({
						content: positions[i].content // 인포윈도우에 표시할 내용
					});
					// 즉시실행함수
					(function(marker, infowindow) {
						// 마커에 mouseover이벤트 등록하고 마우스 오버됐을때 인포윈도우 표시 처리
						kakao.maps.event.addListener(marker, 'mouseover', function() {
							infowindow.open(map, marker)
						})
						// 마커에 mouseout이벤트 등록하고 마우스가 아웃됐을때 인포윈도우 닫기 처리
						kakao.maps.event.addListener(marker, 'mouseout', function() {
							infowindow.close()
						})
					})(marker, infowindow)
				}
			</script>
		</div>
	</div>
</body>
</html>

댓글