Spring Boot + React 연동 / Security 기본 로그인 및 권한 구현

fever·2024년 5월 16일
1

Spring Boot/React 프로젝트를 하면서
어려웠던 개념을 다 잡기 위해 다시 하는 공부!

💻 구현 기능

시큐리티를 활용한 기본 로그인, 인증, 로그아웃

⭐️ 작동 순서
1. 로그인 시 스프링부트에서 시큐리티 객체를 생성하고, 세션 식별자가 있는 JSESSIONID를 쿠키에 저장 시킴
2. 인증이나 로그아웃 시 리액트에서 쿠키에 저장된 JSESSIONID를 스프링부트로 전달 (전달과정에서 withCredentials: true를 통해 요청 시 쿠키를 읽을 수 있게 스프링부트로 전달해야 함)
3. 스프링부트에선 JSESSIONID를 읽어 세션을 식별하고 관리

📌 구현 과정

✔️ 개발환경 세팅

  • 스프링부트 2.7.14
  • 자바 17
  • 그래들 설정

application.properties

spring.application.name=back
spring.output.ansi.enabled=always

#MySQL 설정
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/TEST?serverTimezone=UTC&characterEncoding=UTF-8

spring.datasource.username=test
spring.datasource.password=a123

#jpa 쿼리문 확인 가능
spring.jpa.show-sql=true

# DB의 고유 기능 사용 가능
spring.jpa.hibernate.ddl-auto=update

# SQL의 가독성 높임(JPA 구현체인 Hibernate 동작)
spring.jpa.properties.hibernate.format_sql=true

# 디버그 모드 활성화
logging.level.org.springframework.security=DEBUG

SecurityConfig.java

  • 리액트와 스프링부트를 함께 사용할 시 CorsFilter 설정 필수 (서로 포트가 다르기 때문에)
  • jwt가 아닌 일반 로그인으로 사용할 거라, 폼로그인으로 설정 진행
  • 보통 시큐리티 같은 경우 유저이름으로 하는데, 이메일로 진행할거라 usernameParameter로 이메일 설정 (*설정 안하면 기본값 네임으로 폼을 넘겨줘야 함)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder encoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/admin/**").hasRole("ADMIN") // /admin/** 접근은 ADMIN 권한을 가진 사용자만 가능
                .antMatchers("/user/**").authenticated() // /user/** 접근은 인증된 사용자만 가능
                .anyRequest().permitAll()
                .and()
                .formLogin() // 로그인 설정
                .loginPage("/login")
                .loginProcessingUrl("/loginProc") // 실제 로그인 처리 엔드 포인트
                .usernameParameter("email") // form에서 email 사용
                .defaultSuccessUrl("/loginOk")
                .and()
                .logout() // 로그아웃 설정
                .logoutUrl("/logout")
                .logoutSuccessUrl("/logoutOk")
                .deleteCookies("JSESSIONID");

        http.addFilterBefore(corsFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("http://localhost:3000");// 리액트 서버
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);

        return new CorsFilter(source);
    }

}

UserDetailsServiceImpl.java

  • UserDetailsService를 구현하여 데이터베이스에서 사용자 조회하고 UserDetails 객체 반환
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 java.util.Collections;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

import com.study.back.repository.UserRepository;
import com.study.back.model.User;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private final UserRepository userRepository;

    public UserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        System.out.println("넘어온 이메일: " + email);
        System.out.println("loadUserByUsername 실행");

        // 사용자 조회, 없으면 예외 발생
        User user = userRepository.findByUserEmail(email);
        if (user == null) {
            throw new UsernameNotFoundException("User not found with email: " + email);
        }

        // 사용자가 있다면 UserDetails 객체 생성
        return new org.springframework.security.core.userdetails.User(
                user.getUserEmail(),
                user.getPassword(),
                Collections.singleton(new SimpleGrantedAuthority(user.getRole())));
    }

}

User.java

  • 간단한 유저 관리 엔티티 생성
  • 중복을 막기 위해 이메일은 유니크로 설정
import java.sql.Timestamp;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

import org.hibernate.annotations.CreationTimestamp;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Builder
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class User {
    @Id // primary key
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int no;
    private String userName;
    private String password;

    @Column(unique = true)
    private String userEmail;

    private String role; // ROLE_USER, ROLE_ADMIN

    @CreationTimestamp
    private Timestamp createDate;
}

UserRepository.java

  • 데이터 조회 반환을 위해 이메일 메서드 추가
import org.springframework.data.jpa.repository.JpaRepository;

import com.study.back.model.User;

public interface UserRepository extends JpaRepository<User, Integer> {

    User findByUserEmail(String userEmail);

}

UserController.java

  • ResponseEntity를 활용해 상태코드로 반환값 지정
import org.springframework.http.ResponseEntity;
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 org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import com.study.back.model.User;
import com.study.back.service.UserService;

import java.util.Map;
import java.util.HashMap;

import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @PostMapping("/join")
    public ResponseEntity<Void> join(@RequestBody User user) {
        System.out.println("회원가입 컨트롤러 실행" + user);
        userService.joinUser(user);
        System.out.println("회원가입 완료");
        return ResponseEntity.ok().build();
    }

    @GetMapping("/loginOk")
    public ResponseEntity<Map<String, String>> loginOk() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String email = authentication.getName();
        String authorities = authentication.getAuthorities().toString();

        System.out.println("로그인한 유저 이메일:" + email);
        System.out.println("유저 권한:" + authentication.getAuthorities());

        Map<String, String> userInfo = new HashMap<>();
        userInfo.put("email", email);
        userInfo.put("authorities", authorities);

        return ResponseEntity.ok(userInfo);
    }

    @GetMapping("/logoutOk")
    public ResponseEntity<Void> logoutOk() {
        System.out.println("로그아웃 성공");
        return ResponseEntity.ok().build();
    }

    @GetMapping("/admin")
    public ResponseEntity<Void> getAdminPage() {
        System.out.println("어드민 인증 성공");
        return ResponseEntity.ok().build();
    }

    @GetMapping("/user")
    public ResponseEntity<User> getUserPage() {
        System.out.println("일반 인증 성공");

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String email = authentication.getName();

        // 유저 정보
        User user = userService.getUserInfo(email);

        return ResponseEntity.ok(user);
    }
}

UserService.java

  • 가입시 비밀번호와 인코딩 및 롤 설정
  • 유저 정보 반환 서비스 생성
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import com.study.back.model.User;
import com.study.back.repository.UserRepository;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public void joinUser(User user) {
        String rawPassword = user.getPassword();
        String encPassword = bCryptPasswordEncoder.encode(rawPassword);
        System.out.println("비밀번호 인코딩:" + encPassword);
        user.setPassword(encPassword);
        user.setRole("ROLE_USER");
        userRepository.save(user);
    }

    public User getUserInfo(String email) {
        User user = userRepository.findByUserEmail(email);
        return user;
    }

}

Join.js

  • 회원가입 페이지
  • 유저 엔티티와 컬럼 이름을 맞춰줘야지 유저 엔티티로 받을 수 있음
import React, { useEffect, useState } from 'react';
import axios from 'axios';

function Join() {
  const [user, setUser] = useState({
    userName: '',
    userEmail: '',
    password: '',
  });

  const handleChange = (e) => {
    const { id, value } = e.target;
    setUser({ ...user, [id]: value });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      await axios.post('http://localhost:8080/join', user);
      alert('회원가입 완료');
      window.location.href = '/';
    } catch (error) {
      console.log('회원가입 에러: ' + error);
    }
  };

  return (
    <div>
      <h3>회원가입</h3>
      <form onSubmit={handleSubmit}>
        <input type="text" id="userName" value={user.userName} placeholder="이름" onChange={handleChange} />
        <input type="text" id="password" value={user.password} placeholder="비밀번호" onChange={handleChange} />
        <input type="text" id="userEmail" value={user.userEmail} placeholder="이메일" onChange={handleChange} />
        <button type="submit">회원가입</button>
      </form>
    </div>
  );
}

export default Join;

Login.js

  • 로그인 요청을 하면 바로 시큐리티를 통하기 때문에 시큐리티 설정에서 지정한 usernameParameter에 따라 email로 보내야 함.
  • axios는 기본적으로 json으로 전송하기 때문에 formData로 바꿔서 보내줘야 함 (시큐리티에서 .formLogin() 으로 설정 했음)
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import axios from 'axios';

function Login() {
  const navigate = useNavigate();

  const [user, setUser] = useState({
    email: '',
    password: '',
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setUser({ ...user, [name]: value });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      const formData = new FormData();
      formData.append('email', user.email);
      formData.append('password', user.password);

      const response = await axios({
        url: 'http://localhost:8080/loginProc',
        method: 'POST',
        data: formData,
        withCredentials: true,
      });
      if (response.status === 200) {
        alert('로그인 성공! ');
        console.log('유저 이메일: ' + response.data.email);
        console.log('권한: ' + response.data.authorities);
        navigate('/home', { state: { userData: response.data } });
      }
    } catch (error) {
      console.log('로그인 에러: ', error);
    }
  };

  return (
    <div>
      <h3>로그인</h3>
      <form onSubmit={handleSubmit}>
        <input type="text" name="email" placeholder="이메일" value={user.email} onChange={handleChange} />
        <input type="password" name="password" placeholder="비밀번호" value={user.password} onChange={handleChange} />
        <button type="submit">로그인</button>
      </form>
      <Link to="/join">
        <button>회원가입</button>
      </Link>
    </div>
  );
}

export default Login;

Home.js

  • 로그인 후 이동하는 페이지
  • 로그인 때 받았던 데이터를 보여줌
import React from 'react';
import { useLocation } from 'react-router-dom';
import axios from 'axios';

function Home() {
  const location = useLocation();
  const { email, authorities } = location.state.userData;

  const handleLogout = async () => {
    try {
      await axios.post('/logout');
      alert('로그아웃 완료');
      window.location.href = '/';
    } catch (error) {
      console.error('로그아웃 에러:', error);
    }
  };

  const goToUserPage = () => {
    window.location.href = '/userInfo';
  };

  const goToAdminPage = () => {
    window.location.href = '/admin';
  };

  return (
    <div>
      <h1>사용자 정보</h1>
      <p>이메일: {email}</p>
      <p>권한: {authorities}</p>
      <button onClick={handleLogout}>로그아웃</button>
      <button onClick={goToUserPage}>마이 페이지</button>
      <button onClick={goToAdminPage}>어드민 페이지</button>
    </div>
  );
}

export default Home;

UserInfo.js

  • 로그인한 사람만 들어갈 수 있는 마이페이지
  • 시큐리티 설정 때문에 권한이 있어야지만 페이지 정보가 보임
  • 리액트에서 스프링부트로 요청시 withCredentials: true 를 보내줘야지만 세션 쿠키를 부트에서 읽을 수 있음
import React, { useEffect, useState } from 'react';
import axios from 'axios';

function UserInfo() {
  const [user, setUser] = useState({
    userName: '',
    userEmail: '',
    role: '',
  });

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await axios.get('http://localhost:8080/user', {
          withCredentials: true, // 자격 증명(쿠키, 인증 헤더 등)을 포함하여 HTTP 요청
        });
        if (response.status === 200) {
          setUser(response.data);
        }
      } catch (error) {
        console.error('Error checking user status:', error);
      }
    };
    fetchData();
  }, []);

  const handleLogout = async () => {
    try {
      await axios.post('/logout');
      alert('로그아웃 완료');
      window.location.href = '/';
    } catch (error) {
      console.error('로그아웃 에러:', error);
    }
  };

  return (
    <div>
      <h1>마이페이지</h1>
      <p>이름: {user.userName}</p>
      <p>이메일: {user.userEmail}</p>
      <p>권한: {user.role}</p>
      <button onClick={handleLogout}>로그아웃</button>
    </div>
  );
}

export default UserInfo;

admin.js

  • 어드민 권한만 들어갈 수 있는 페이지
import React, { useEffect, useState } from 'react';
import axios from 'axios';

function Admin() {
  const [isAdmin, setIsAdmin] = useState(false);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await axios.get('http://localhost:8080/admin', {
          withCredentials: true, // 자격 증명(쿠키, 인증 헤더 등)을 포함하여 HTTP 요청
        });
        if (response.status === 200) {
          setIsAdmin(true);
        }
      } catch (error) {
        console.error('Error checking admin status:', error);
      }
    };
    fetchData();
  }, []);

  const handleLogout = async () => {
    try {
      await axios.post('/logout');
      alert('로그아웃 완료');
      window.location.href = '/';
    } catch (error) {
      console.error('로그아웃 에러:', error);
    }
  };

  return (
    <div>
      {isAdmin ? <div>어드민 페이지입니다.</div> : <div>권한이 없습니다.</div>}
      <button onClick={handleLogout}>로그아웃</button>
    </div>
  );
}

export default Admin;

구현 결과

  1. 로그인

  1. 마이페이지

  2. 어드민페이지

💻 git

https://github.com/fever-max/SpringBoot-React-Security-login

profile
선명한 삶을 살기 위하여

0개의 댓글