본문 바로가기

SpringBoot/Blog프로젝트 with JPA &데어프로그래밍님

21.1.12 TIL - xss와 csrf란? 스프링 시큐리티를 이용한 로그인 구현

※본 강의는 데어프로그래밍님 스프링부트 블로그프로젝트 강의 수강 후 작성한 내용입니다.

 

[xss와 csrf란?]

xss란 자바스크립트 공격읻. 예를들어 게시판이라고 했을 때 글쓰기에서 제목부분에 자바스크립트로 alert를 5만번 나오게 하는 코드를 작성해서 글쓰기를 완료하면 게시판에서 제목부분의 자바스크립트를 읽어서 alert가 5만번이 뜨며 페이지가 다운되게 하는 공격이다. 이 공격을 막기위해서는 <나 <script>를 막으면 되는데 lucy xss filter에서 이 공격을 막기위한 필터를 제공한다.

 

csrf에 대해서도 그 예를들어 간단하게 적어보자면 뭔가 중요한 정보를 파라미터로 받아서 update하는 url이 있다고 했을 때 해당 url은 권한이 ADMIN인 사람만 들어갈 수 있도록 프로그래밍을 해둘 것이다. 공격자는 관리자가 어떤 이미지를 클릭하도록 유도하고 그 이미지에 해당 url로 들어가는 하이퍼링크를 달아둔다. 이미지에 <a href="ADMIN만 접속가능한 url"><img src="~"></a> 를 해두는 것이다. 이럴경우 관리자가 해당 url로 접속하게 되기 때문에 공격자가 원한대로 정보가 update된다.

이 공격을 막기위해서 일단 첫번째로 이러한 요청은 GET방식이 아닌 POST방식으로 받아야한다. 또 다른 방법으로는 같은 도메인 상에서 요청이 들어오지 않는다면 차단하도록 하는 Referrer검증방법, 사용자 세션에 csrf토큰을 저장해서 요청에 대해 서버단이 검증하는 방법 등이 있다. 토큰을 사용하는경우 사용자는 요청을 할때마다 그 토큰을 같이 보내게 되는데 서버측에서 요청을 보고 토큰이 정상적으로 왔으면 정상적인 사용자임을 판단한다. 

 

[스프링 시큐리티를 이용한 로그인 구현 코드]

com.cos.blog.auth패키지 생성

해당패키지에 PrincipalDetail.java, PrincipalDetailService.java 클래스 생성

 

PrincipalDetail.java

package com.cos.blog.auth;

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

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

import com.cos.blog.model.User;

//스프링시큐리티가 로그인요청을 가로채서 로그인을 진행하고 완료가 되면 User Details타입의 오브젝트를
//스프링시큐리티의 고유한 세션저장소에 저장을 해준다. 그때 저장되는게 UserDetails타입의 PrincipalDetail이 저장되는 것이다.
//그래서 이게 저장될때 우리가 만들어둔 User가 포함되어있어야한다.
public class PrincipalDetail implements UserDetails{
	private User user; //이렇게 user객체를 품고있는걸 콤포지션이라고 함.
	//alt+shift+s해서 override method하면 한번에 다 해올수있음

	public PrincipalDetail(User user) {
		this.user = user;
	}

	@Override
	public String getPassword() {
		return user.getPassword();
	}

	@Override
	public String getUsername() {
		return user.getUsername();
	}

	//계정이 만료되지 않았는지를 리턴한다. 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;
	}
	
	//계정권한리턴. 리턴타입 주의. 권한이 여러개 있을 수 있어서 루프를 돌아야하는데 우리는 한개만
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> collectors = new ArrayList<>();
//		collectors.add(new GrantedAuthority() {
//			
//			@Override
//			public String getAuthority() {
//				return "ROLE_"+user.getRole(); //스프링에서 role받을때 규칙. 앞에 ROLE_을 꼭 붙여서 리턴해주어야함.
//			}
//		});
		//위에꺼를 람다식 사용해서 표현한것. 들어갈수있는게 하나씩밖에 없기때문에 가능.
		collectors.add(()->{return "ROLE_"+user.getRole();});
		return collectors;
	}
	
}

 

 주석에도 적혀있지만 스프링에서는 Role을 리턴할 때 앞에 ROLE_을 붙여줘야한다! 그래야 스프링이 role이라고 인식함. 

 

PrincipalDetailService.java

package com.cos.blog.auth;

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

import com.cos.blog.model.User;
import com.cos.blog.repository.UserRepository;

@Service //Bean에 등록
public class PrincipalDetailService implements UserDetailsService{
	
	@Autowired
	private UserRepository userRepository;
	
	//스프링이 로그인요청을 가로챌 때, username, password 변수 2개를 가로채는데
	//password부분 처리는 알아서한다. username이 db에 있는지만 확인해주면됨. 그 확인을 아래함수에서 한다.
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		User principal = userRepository.findByUsername(username)
				.orElseThrow(()->{
					return new UsernameNotFoundException("해당 사용자를 찾을 수 없습니다:"+username);
				});
		return new PrincipalDetail(principal);
	}
}

 

SecurityConfig.java

package com.cos.blog.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.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.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import com.cos.blog.auth.PrincipalDetailService;


//이 세개가 시큐리티 세트임
@Configuration //빈등록:스프링컨테이너에서 객체를 관리할 수 있게 하는것. IoC로 관리됨.
@EnableWebSecurity //시큐리티 필터로 등록이 된다.
@EnableGlobalMethodSecurity(prePostEnabled = true) //특정주소로 접근하면 권한 및 인증을 미리 체크하겠다.
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	
	@Autowired
	private PrincipalDetailService principalDetailService;
	
	//Bean 어노테이션은 메서드에 붙여서 객체 생성시 사용
	@Bean //IoC가 된다. 즉, 이 함수가 리턴하는 값을 스프링이 관리한다. 그래서 필요할때마다 가지고와서 쓰면된다.
	public BCryptPasswordEncoder encodePWD() {
		return new BCryptPasswordEncoder(); //이객체를 통해서 .encode("1234") 해서 hash할 수 있음
	}
	
	//시큐리티가 대신 로그인해주는데 password를 가로채기를하는데 
	//해당 password가 뭘로 해쉬가 되어 회원가입이 되었는지 알아야
	//같은 해쉬로 암호화해서 db에 있는 해쉬랑 비교할 수 있음. 이거안하면 password비교못함
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception{
		auth.userDetailsService(principalDetailService).passwordEncoder(encodePWD());
	}
	
	//필터링
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
		.csrf().disable() //csrf tocken비활성화(테스트시 걸어주는것이 좋음)
			.authorizeRequests() //request가 들어오면
			.antMatchers("/","/css/**","/images/**","/js/**","/auth/**") //이 주소로 들어오는 애들은 누구나 들어올 수 있다
			.permitAll()
			.anyRequest().authenticated() //이게아닌 다른요청은 인증이 되어야한다. 그래서 loginForm으로 간다.
			.and()
			.formLogin().loginPage("/auth/loginForm")
			.loginProcessingUrl("/auth/loginProc") //스프링시큐리티가 해당주소로 오는 로그인을 가로채서 대신 로그인해준다. 이거때문에 userDetails type을 가지고있는 user object를 만들어야함. 
			.defaultSuccessUrl("/"); //로그인끝나면 이 주소로 가게해줌
	}
}

코드에보면 .csrf().disable()이 있는데 우리는 ajax 자바스크립트를 이용해서 요청했기 때문에 csrf토큰이 없다. 따라서 서버에서 막아버리기 때문에 csrf token을 disable해둔것이다. 나중에 이 방법을 사용하고 싶다면 csrf token을 날리도록 설정하고 해당 코드를 지우면된다.

 

UserApiController.java

package com.cos.blog.controller.api;

import javax.servlet.http.HttpSession;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.cos.blog.dto.ResponseDto;
import com.cos.blog.model.RoleType;
import com.cos.blog.model.User;
import com.cos.blog.service.UserService;

@RestController
public class UserApiController {
	
	@Autowired
	private UserService userService; //@Service해놔서 IoC에 등록되기 때문에 dependency 인잭션?해서 사용할 수 있음
	
	//spring 시큐리티가 /auth/loginProc가로채게 할것이기 때문에 여기에 안만듦
	
	@PostMapping("/auth/joinProc")
	public ResponseDto<Integer> save(@RequestBody User user) {
		System.out.println("UserApiController: save호출됨");
		//return new ResponseDto<Integer>(200,1); //data가 1, status가 200. 200은 통신이 정상적으로 작동했다는 뜻.
		
		//user.setRole(RoleType.USER); //Service에서 하는걸로 변경!
		userService.회원가입(user); //받은걸 그대로 넣어서 회원가입 하면됨.
		return new ResponseDto<Integer>(HttpStatus.OK.value(),1); //위에꺼랑 같은 의미. 
		//이렇게 하려면 ResponseDto의status type을 HttpStatus로 해야함. 이게 위에꺼보다 안전함. 데이터부분은 나중에 디비에 저장하고 리턴된 값이 들어갈것임
	}
}

UserRepository.java

package com.cos.blog.repository;

import java.util.Optional;

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

import com.cos.blog.model.User;

//받은 데이터를 db에 저장하기 위한 interface
//자동으로 bean등록이 되기때문에 @Repository 생략가능
public interface UserRepository extends JpaRepository<User, Integer>{ 
	//해당 인터페이스는 user table이 관리하는 repository이고, user table의 primary key는 integer라는 뚯
	
	//JPA naming 쿼리
	Optional<User> findByUsername(String username);
}

UserService.java

package com.cos.blog.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.cos.blog.model.RoleType;
import com.cos.blog.model.User;
import com.cos.blog.repository.UserRepository;

@Service //이 어노테이션이 있어야 스프링이 컴포넌트 스캔을 통해서 Bean에 등록해준다.IoC를 해준다는 뜻
public class UserService {
	
	@Autowired
	private UserRepository userRepository;
	
	@Autowired
	private BCryptPasswordEncoder encoder;
	
	@Transactional
	public void 회원가입(User user) {
		String rawPassword = user.getPassword(); //password원문
		String encPassword = encoder.encode(rawPassword); //hash된 password
		user.setPassword(encPassword);
		user.setRole(RoleType.USER);
		userRepository.save(user);
	}
}

스프링 시큐리티를 사용해서 로그인하려면 회원가입할 때 비밀번호를 hash해서 DB에 insert해야한다.

 

loginForm.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"%>

<%@ include file="../layout/header.jsp" %>
<div class="container">
<form action="/auth/loginProc" method="post">

  <div class="form-group">
    <label for="Username">Username</label>
    <input type="text" name="username" class="form-control" placeholder="Enter username" id="username">
  </div>
 
  <div class="form-group">
    <label for="pwd">Password</label>
    <input type="password" name="password" class="form-control" placeholder="Enter password" id="password">
  </div>

  <button id="btn-login" class="btn btn-primary">로그인 완료</button>
</form>

</div>
<br />
<%@ include file="../layout/footer.jsp" %>

지난시간에 button이동했던걸 다시 form 안으로 넣었다.

 

댓글