본문 바로가기

프로젝트

[SpringBoot] 경매사이트 만들기 - 회원가입 및 로그인(시큐리티 적용2)

이전 포스팅에선 수정사항, 회원가입 페이지 및 member.js까지 알아봤다.

 

1. 수정사항(완료)

2. 회원가입 페이지 구현(완료)

3. 로그인 페이지 구현

4. 시큐리티 적용해서 회원가입 로그인해보기!

 

그리고 이번 포스팅에선 3번, 4번을 포스팅 하려고한다 ㅎㅎㅎ

 

● 로그인!!

회원가입은 버튼을 누르게되면 js에서 ajax를 통해 회원가입을 해서 버튼이 form태그가 바깥에 있지만 하지만 로그인의 경우 스프링 시큐리티가 대신 해주기 때문에 form태그 안에 버튼이 있어야하고, 당연하게도 form태그의 action과 method를 정해줘야한다.

 

(로그인 페이지)

 

LoginForm.jsp

<div class="col-lg-6 mt-5 mt-lg-0">
	<form action="${pageContext.request.contextPath}/login" method="post" role="form" class="php-email-form">
		<div class="form-group mt-3">
			<p>
				<input type="text" name="email" id="email" class="kdg-join-css" placeholder="Email" required>
			</p>
		</div>
		<div class="form-group mt-3">
			<p>
				<input type="text" name="passWord" id="passWord" class="kdg-join-css" placeholder="PassWord" required>
			</p>
		</div>
		<div class="my-3">
			<a href="#">Find My Id</a> 
			<label style="color: #ffc451;">&nbsp;&nbsp; / &nbsp;&nbsp;</label> 
			<a href="#">Find My Password</a>
		</div>
		<div class="text-center">
			<button type="submit" id="loginButton" class="kdg-join-button-css">Login</button>
		</div>
	</form>
	<div class="my-3"></div>
	<div class="text-center">
		<button class="kdg-join-button-css" onclick="location.href='joinForm'">Join</button>
	</div>
</div>

로그인은 앞서 말했다시피 시큐리티가 대신 로그인해주기 때문에 따로 컨트롤러와 서비스를 정의할 필요읎다~

내가 해야될껀 스프링 시큐리티의 커스터마이징뿐....(이게 난 넘 어려웠으뮤 ㅠㅠ)

 


● 시큐리티 설정하기!

회원가입과 로그인을 구현하기전 먼저 시큐리티에 대한 설정을 해줘야한다! 이유는

 

첫번째로 회원가입시 비밀번호는 무조건 해시로 암호화 해서 넣어줘야 시큐리티가 알아듣는다.

두번째로 첫번째의 연장선인데 로그인할때 암호화된 비번을 사용자 입력값과 비교하기 위해서이다.

세번째로 로그인을 시큐리티가 대신해주게 하려면 시큐리티 설정은 필수.

네번째로 나중에 사용자 권한별로 접근할 수 있는 서비스를 제한하기 위해서!

 

일단은 이 4가지 이유가 있기때문에 어찌됐든 시큐리티 설정을 해준다!

그러기 위해서 첫번째로 해야할것은 WebSecurityConfig 클래스 생성!

 

WebSecurityConfig.java

package org.my.toyproject.config.security;

import org.my.toyproject.config.auth.PrincipalDetailService;
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;

@Configuration//빈등록
@EnableWebSecurity//시큐리티 필터 추가
//prePostEnabled는 특정 주소로 접근시 권한 및 인증 체크, securedEnabled는 @Secured를 사용하기 위함.
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
	
	//userDetails를 담고 있는 객체다.
	@Autowired
	private PrincipalDetailService principalDetailService;
	
	//비번 암호화 할때 필요함. 시큐리티에 내장돼 있음.
	@Bean
	public BCryptPasswordEncoder passwordEncoder() {
		BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
		return encoder;
	}
	
	//시큐리티가 비번 가로채고 대신 로그인해주는데
	//가로챈 비번이 뭐로 해쉬처리 돼서 가입했는지 알기 위함.
	//이걸알아야 로그인할때 DB에 있는 암호화된 비번이랑 비교해서 로그인해줄수 있음.
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(principalDetailService).passwordEncoder(passwordEncoder());
	}
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		//테스트시에만 이렇게 설정해준다.
		//이후에 form태그안에 csrf토큰으로 이거대신 쓴다.
		http.csrf().disable();
		
		//어떤 요청이 들어왔을때 ("/","/home","/bootstrap/**","/auth/**")해당하는 url은 전부 허용.
		//즉, 인증이 안된 사용자들도 모두 접근할수 있음.
		//그 외의 요청은 인증이 필요함.
		http.authorizeRequests().antMatchers("/","/home","/bootstrap/**","/auth/**").permitAll()
			.anyRequest().authenticated();
		
        //위에서 permitAll한거를 제외한 다른 모든 요청은 여기로 온다~
		//로그인
		http.formLogin().loginPage("/auth/loginForm")//로그인할수 있는 page의 url을 써줌.
			.loginProcessingUrl("/login")//시큐리티가 해당주소로 오는 요청을 가로채서 대신 로그인해줌.
            								//form에 action에 있는 주소임.
			.failureUrl("/login_fail")//로그인 실패시 url 
			.defaultSuccessUrl("/home")//성공시 url
			.usernameParameter("email").passwordParameter("passWord")//유저가 입력하는 email,pw
			.and()
			.formLogin().permitAll();
	}
}

가장 첫번째로 만들고 설정해 줘야하는 클래스다. 주석을 달아놨지만 간단히 설명해보면

private PrincipalDetailService principalDetailService; -> 커스터마이징한 UserDetails 객체를 담고 있다.

public BCryptPasswordEncoder passwordEncoder() {} -> 회원가입시 비번 암호화 해준다.(로그인시엔 비번을 시큐리티가 알아서 체크해줌)

protected void configure(AuthenticationManagerBuilder auth) throws Exception {} -> 위에서 비번을 시큐리티가 알아서 체크해준다 했는데 그걸 위해서 이함수를 오버라이딩 해줘야함.

protected void configure(HttpSecurity http) throws Exception {} -> 사용자의 요청을 어떻게 처리할껀지에 대해 정의하는 곳이다. 사실 위에서 http 변수 하나로 .and()로 이어서 해도되지만 역할을 구분하기 위해 나눠놨다ㅎㅎ

 

PrincipalDetail.java

package org.my.toyproject.config.auth;

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

import org.my.toyproject.model.Authority;
import org.my.toyproject.model.Member;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

//스프링시큐리티가 로그인요청을 가로채서 로그인을 진행하고 완료가 되면 UserDetails 타입의 객체를
//스프링시큐리티의 고유한 세션저장소에 저장을 해준다.
//즉, member객체를 UserDetails타입으로 만들어준다. 그래야 고유세션에 저장이되고,
//나중에 DI로 객체를 갖고올수 있음.
@SuppressWarnings("serial")
public class PrincipalDetail implements UserDetails{
	
	private Member member; //콤포지션
	
	public PrincipalDetail(Member member) {
		this.member = member;
	}
	
	public String getEmail() {
		return member.getEmail();
	}
	public void setEmail(String email) {
		member.setEmail(email);
	}
	
	public String getNick() {
		return member.getNick();
	}
	public void setNick(String nick) {
		member.setNick(nick);
	}
	
	public String getPhone() {
		return member.getPhone();
	}
	public void setPhone(String phone) {
		member.setPhone(phone);
	}
	
	public String getBankAccount() {
		return member.getBankAccount();
	}
	public void setBankAccount(String bankAccount) {
		member.setBankAccount(bankAccount);
	}
	
	public String getBankName() {
		return member.getBankName();
	}
	public void setBankName(String bankName) {
		member.setBankName(bankName);
	}
	
	
	@Override
	public String getPassword() {
		return member.getPassWord();
	}

	@Override
	public String getUsername() {
		return member.getName();
	}
	
	//계정이 만료되지 않았는지 리턴한다(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() {
		System.out.println("흐어...");
		List<Authority> list = member.getAuthorityList();
		if(list.size() == 0){
			throw new UsernameNotFoundException("아무 권한이 없습니다.");
		}
	        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
         	for(Authority au:list) {
        	    authorities.add(new SimpleGrantedAuthority(au.getAuthorityRole()));
        	}
		return authorities;
	}
	
}

시큐리티는 고유의 세션이 존재한다 -> 그래서 로그인을 하면 그 세션에서 사용자 정보나 그런걸 불러와서 사용하는 방식인데 시큐리티 세션에 객체를 넣을때 Member객체를 넣어줄수 없다. -> UserDetails로 감싸서 객체로 넣어줘야 들어간다.

그래서 UserDetails를 상속받은 클래스를 정의해줘야한다!

기본적으로 오버라이딩 해야하는것들을 제외하고, 내가 필요한 부분을 더 갖고오려면 gettersetter를 만들어줘야함!

 

그리고 가장밑에 권한목록을 불러오는 부분은 사실 권한이 하나면 for문을 돌릴필요 없는데 나는 권한이 최소 2개라 for문을 돌려줘야 했다. 

그분은 Member.java에서 @OneToMany로 설정한 권한 목록들을 갖고와서 List에 담고,

list.size()==0이면 회원가입이 안됐거나, 권한이 없다는 뜻이고,

List<SimpleGrantedAuthority> authorities = new ArrayList<>(); 객체를 생성하고,

아까 불러온 모든 권한을(SimpleGrantedAuthority->String타입만 가능) 담아준뒤에 반환해준다.

List로 하는 이유는 List의 상위타입이 결국 Collection이라서 가능함.

 

여튼 이렇게 Member객체 대신 세션에 넣어줄수 있는!! , 동일한 효과를 갖고 있는 UserDetails를 상속한 클래스를 만든다.

 

PrincipalDetailService.java

package org.my.toyproject.config.auth;

import org.my.toyproject.model.Member;
import org.my.toyproject.repository.MemberRepository;
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;

@Service
public class PrincipalDetailService implements UserDetailsService{
	
	@Autowired
	private MemberRepository memberRepository;
	
	//스프링이 로그인 요청을 가로챌때 username,password 변수 2개를 가로챔.
	//이때 password부분은 스프링이 알아서 처리해주고,
	//개발자는 username이 DB에 있는지만 아래처럼 확인해서 return해주면됨.
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		Member principal = memberRepository.findByEmail(username)
				.orElseThrow(()->{
					return new UsernameNotFoundException("해당 사용자 이메일을 찾을수 없습니다:"+username);
				});
		return new PrincipalDetail(principal);//시큐리티 세션에 유저정보가 저장됨.
		//만약 이걸 구현하지 않으면 아이디:user, 비번:콘솔창에 뜨는거 로 자동 설정된다.
		//한마디로 커스터마이징한거를 쓰지 못함.
	}
}

일단 이거는 쉽게 말하면 로그인할때 유저 정보를 담은 UserDetails로 DB에 있는지 없는지 확인하기 위해 만들어야하는 클래스다.

매개변수로 username을 받는데 이걸 이용해서 JPA로 DB에 username에 해당하는 친구가 있는지 없는지 확인한다~

확인한뒤에서 없으면 에러던지고, 있으면 이전에 만들어놨던 pricipalDetail(UserDetails)로 감싸진 유저 정보 객체를 반환해주면 된다!

그리고 이 클래스에 @Service 를 다는이유는 WebSecurityConfig 파일에서 DI 해야하기 때문이라는걸 생각하자.

아... 그리고 Member principal = memberRepository.findByEmail(username) -> 이부분의 findByEmail은

memberRepository에서 함수를 새로 만들어줘야한다. 이유는 저 함수가 없으니깐~

 

MemberRepository.java

package org.my.toyproject.repository;

import java.util.Optional;

import org.my.toyproject.model.Member;
import org.springframework.data.jpa.repository.JpaRepository;


//DAO
//자동으로 Bean등록이 되기때문에 @Repository 생략가능.
//한마디로 알아서 ioc컨테이너에 객체 생성되고, 필요할때 DI 가능
//제네릭: <Entity클래스, pk 타입>
public interface MemberRepository extends JpaRepository<Member, Long>{
	Optional<Member> findByEmail(String email);
}

Optional을 사용한 이유는 자바8 이었나.. 암튼 그때부터 NEP 즉, NullPointException(스펠링 이거 아닐수도..?)예외가 있을때 개발자가 따로 로직을 작성하지 않아도 NEP에 걸리는 예외를 처리해주기 위한 함수다. 자바에선 이를 오직 반환타입으로만 사용하는걸 권하고있다~. 그니까 매개변수,인스턴스 변수 등.. 이런데서 쓰지말자구용

 


● 회원가입(컨트롤러, 서비스)

 

MemberApiController(RestController)

package org.my.toyproject.controller.api;

import org.my.toyproject.model.Member;
import org.my.toyproject.service.MemberService;
import org.my.toyproject.vo.ResponseVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MemberApiController {
	
	@Autowired
	private MemberService memberService;
	
	@PostMapping("auth/joinProc")
	public ResponseVo<Integer> save(@RequestBody Member member) {
		System.out.println("MemberApiController: save 호출");
		memberService.joinService(member);
		//HttpStatus는 응답 상황 어케된건지, 그옆은 응답할 data가옴.
		return new ResponseVo<Integer>(HttpStatus.OK.value(),1);//Java객체를 JSON으로 반환
	}
}

 

MemberService

package org.my.toyproject.service;

import org.my.toyproject.model.Authority;
import org.my.toyproject.model.Member;
import org.my.toyproject.repository.AuthorityRepository;
import org.my.toyproject.repository.MemberRepository;
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;

//스프링이 컴포넌트 스캔을 통해서 Bean에 등록을 해줌. IoC를 해준다.
//즉, 메모리에 대신 띄워준다!
@Service
public class MemberService {
	
	@Autowired
	private MemberRepository memberRepository;
	
	@Autowired
	private AuthorityRepository authorityRepository;
	
	@Autowired
	private BCryptPasswordEncoder passwordEncoder; 
	
	@Transactional
	public void joinService(Member member) {
		System.out.println("서비스호출");
		String rawPassWord = member.getPassWord();
		String encPassWord = passwordEncoder.encode(rawPassWord);//비번암호화
		member.setPassWord(encPassWord);
		memberRepository.save(member);
		
		Authority authority = new Authority();
		authority.setAuthorityRole("ROLE_MEMBER");
		authority.setMember(member);
		authorityRepository.save(authority);
		
	}
	
}

회원가입을 할때 권한테이블에 권한도 같이 넣어준다. 근데 이때 반드시 @Transactional 을 붙여서 데이터의 일관성을 유지 시켜줘야한다는걸 주의하자. 

 

회원가입 완료했을때 DB상태

Mebemer 테이블

Member테이블

Authority 테이블

Authority 테이블

참고로 ADMIN 권한을 넣어주는 로직을 아직 안짜서 일단 내가 DML의 insert로 넣어줬다. 

 


● 로그인

로그인은 사실 더이상 쓸게 없다!!!!

왜냐면 위에서 시큐리티 커스터마이징을 할때 말했지만 시큐리티가 로그인을 대신해주게 된다! 그래서 얘는 뭐 따로 컨트롤러가 없다.

그냥 위에서 써놨던 LoingForm.jsp에서 form태그의 action에 명시된 url로 유저가 접근했을때 시큐리가 착~착~ 로그인해준다! 이 얼마나 간편한가 ㅋㅋㅋㅋ(대신 공부하기가 빡셈;)

대신 로그인 완료했을때 콘솔창을 보면 아래와 같다!

 

로그인 했을때 콘솔창에 정보들 찍어보기!

Member테이블과 Authory테이블에 저장된 모든 칼럼들이 모두 잘 불러와지는걸 볼수 있다!

 

 

● 프로젝트 패키지 계층구조~

 

일단 로그인과 회원가입만 구현해놨기 때문에 아직 컨트롤러,서비스,레파지토리 부분이 별거없다 ㅎㅎ

그래서 더 보기 쉬운듯!


시큐리티에서 머리좀 아팠는데 하고나니까 너무 좋다 ㅎㅎㅎ

 

★ 이슈사항

이슈사항은 포스팅하면서 중간중간 말하긴 했는데 기억나는대로 정리해보면

1. model패키지에 entity 클래스들중 복합키를 가진 클래스는 전부 단일키로 바꿈.

2. 시퀀스전략 재수정.

3. 회원가입하면서 권한도 같이넣기 -> 이전에 권한테이블이 복합키여서 넣기 힘들었는데 단일키로 바꾸면서 해결.

4. 로그인하고 로그인한 유저의 정보를 볼수 있는게 한정됨 -> UserDetails를 만드는 과정에서 gettersetter를 이용해 필요한 정보 전부 만들기.

5. UserDetails를 만들면서 권한목록을 불러올때 어케 불러올지... 첨에 헤맸음 -> Member 엔티티에서 @OneToMany의 EAGER 전략으로해서 권한 객체들을 List<>로 들고와서 해결.

 

일단 당장 생각나는건 이정도다!

 

 

 

★ 다음에 할꺼

1. 경매 게시판 목록 페이지 구현

2. 경매 게시글 페이지 구현

3. 할수 있으면 게시글 작성 구현까지~

 

 

차근차근 해보자구!!!!