본문 바로가기

개발일지/Spring

[React + Spring boot]트위터 클론의 백엔드를 만들어 보자 - ReactJS + Spring login test

반응형

 백엔드의 로그인 구성이 끝났기 때문에 이제 React와 연결을 해서 잘 작동하는지 체크해 보자. 

 통신을 하기 전에 spring과 react의 주소값이 다르기 때문에 CORS설정을 추가해 주어야 한다. 또한 로그인 형식은 formlogin을 사용할 것이기 때문에 SecurityConfig를 다음과 같이 수정했다.

package com.example.nweeter_backend.config;

import com.example.nweeter_backend.handler.CustomAuthenticationFailureHandler;
import com.example.nweeter_backend.handler.CustomAuthenticationSuccessHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.Customizer;
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.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    final private CustomAuthenticationSuccessHandler authenticationSuccessHandler;
    final private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(Customizer.withDefaults())
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/login", "/api/user/signIn").permitAll()
                        .anyRequest().authenticated()
                )
                //.httpBasic(Customizer.withDefaults())
        ;

        http
                .formLogin((config) -> config
                        .loginProcessingUrl("/loginApi")
                        .usernameParameter("email")
                        .successHandler(authenticationSuccessHandler)
                        .failureHandler(customAuthenticationFailureHandler)
                        .permitAll()
                )
        ;
        http
                .logout((config) -> config
                        .logoutUrl("/logout")
                        .deleteCookies("JSESSIONID")
                        .permitAll()
                )
        ;
        http // loginPage disable
                .exceptionHandling(exceptionHandleConfig -> exceptionHandleConfig
                        .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
        ;

        return http.build();
    }


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

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        // h2-console allow
        return (web)-> web.ignoring()
                .requestMatchers(PathRequest.toH2Console())
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:5173"));
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        configuration.setAllowCredentials(true);
        configuration.setAllowedHeaders(List.of("*"));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

 CorsConfigurationSource로 설정을 해주고, 로그인 유무에 따른 간단한 핸들러도 등록해 준다. 또한 로그인의 파라미터를 username에서 email로 변경해 주도록 했다. 패스워드 인코더도 BCryptPasswordEncoder로 변경해 주고 로그인 실패 시 로그인 페이지를 반환해 주는 기능은 끄도록 설정했다.(로그인이 실패해도 페이지가 반환되면서 status값이 200으로 찍히기 때문)

 

로그인 성공과 실패 시 상태값과 메시지를 반환하기 위한 핸들러들을 추가해 준다.

CustomAuthenticationSuccessHandler.java

package com.example.nweeter_backend.handler;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        AuthenticationSuccessHandler.super.onAuthenticationSuccess(request, response, chain, authentication);
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.setStatus(201);
        response.getWriter().write(authentication.getPrincipal().toString());
    }
}

 

CustomAuthenticationFailureHandler.java

package com.example.nweeter_backend.handler;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setStatus(401);
        response.getWriter().write("Id/password check");
    }
}

 

로그인이 이루어질 유저의 엔티티도 수정해 주었다.

 

Member.java (email column 및 timestamp 추가)

package com.example.nweeter_backend.modle;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@NoArgsConstructor
@AllArgsConstructor
@Data
@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false, unique = true)
    private String email;

    @Column
    private String profileImage;

    @CreationTimestamp
    @Column(name = "time_ins")
    private LocalDateTime insertTime;

    @UpdateTimestamp
    @Column(name = "time_upd")
    private LocalDateTime updateTime;

}

 

MemberService.java

package com.example.nweeter_backend.service;

import com.example.nweeter_backend.dto.MemberSignInRequestDto;
import com.example.nweeter_backend.modle.Member;
import com.example.nweeter_backend.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public void save(MemberSignInRequestDto member) {
        Member saveMember = new Member();
        String password = member.getPassword();
        String encPassword = passwordEncoder.encode(password);
        String email = member.getEmail();
        saveMember.setUsername(member.getUsername());
        saveMember.setPassword(encPassword);
        saveMember.setEmail(member.getEmail());
        memberRepository.save(saveMember);
    }
}

 

PrincipalDetailsService.java

package com.example.nweeter_backend.auth;

import com.example.nweeter_backend.modle.Member;
import com.example.nweeter_backend.repository.MemberRepository;
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 PrincipalDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    public PrincipalDetailsService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member principal = memberRepository.findByEmail(email).orElseThrow(()->{
            return new UsernameNotFoundException("해당 사용자를 찾을 수 없습니다. : "+ email);
        });
        return new PrincipalDetails(principal);
    }
}

 email로 로그인을 진행할 것이기 때문에 findByEmail을 MemberRepository에 생성해 준다.

package com.example.nweeter_backend.repository;

import com.example.nweeter_backend.modle.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByUsername(String username);

    Optional<Member> findByEmail(String username);
}

 


reactJS에서는 api 통신을 하기 위한 axios를 설치한다.

npm install axios

 

login의 주소를 기존의 firebase에서 spring으로 변경해 주고 email과 passwrod를 fomdata형식으로 전송한다.

 

login.tsx

import { useState } from "react";
import styled from "styled-components"
import { Link, useNavigate } from "react-router-dom";
import GithubButton from "../components/github-btn";
import axios, { AxiosError } from "axios";

const Wrapper = styled.div`
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 420px;
    padding: 50px 0px;
`;

const Title = styled.h1`
    font-size: 42px;
`;

const Form = styled.form`
    margin-top: 50px;
    margin-bottom: 10px;
    display: flex;
    flex-direction: column;
    gap: 10px;
    width: 100%;
`;

const Input = styled.input`
    padding: 10px 20px;
    border-radius: 50px;
    border: none;
    width: 100%;
    font-size: 16px;
    &[type="submit"]{
        cursor: pointer;
        &:hover {
            opacity: 0.8;
        }
    }
    &.log-in{
        background-color: dodgerblue;
    }
`;

const Error = styled.span`
    color: red;
`;

const Switcher = styled.span`
    margin-top: 20px;
    a{
        color: #1d9bf0;
    }
`;

export default function CreateAccount() {
    const navigate = useNavigate();
    const [isLoading, setLoading] = useState(false);
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    const [loginData, setLoginData] = useState();
    const [error, setError] = useState("");
    const onChange = (e : React.ChangeEvent<HTMLInputElement>) => {
        const {target: { name, value }} = e;
        if (name === "email") {
            setEmail(value);
        }else if (name === "password") {
            setPassword(value);
        }
    }
    const onSubmit =async (e : React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        if(isLoading || email === "" || password === "") return;
        try{
            setLoading(true);
            console.log(email);

            const formData = new FormData();
            formData.append("email", email);
            formData.append("password", password);

            const response = await axios({
                url: "http://localhost:8080/loginApi",
                method: 'POST',
                data: formData,
                withCredentials: true,
            });

            console.log("로그인 status : ",response.status);
            console.log("로그인 data : ",response.data); // username/email
            const userLoginData = response.data.split("/");
            console.log(userLoginData);
            setLoginData(userLoginData);
            console.log(loginData);

            if(response.status == 201) {
                alert("로그인 성공");
                setError("");
                setLoading(false);
                navigate("/");
            }
            
        }catch(e) {
            if(e instanceof AxiosError){
                console.log("error : ", e);
                console.log("e status : ", e.status);
                setError(e.response?.data);
            }
        }finally {
            setLoading(false);
        }
    }
    return (
        <Wrapper>
            <Title>Log into X</Title>
            <Form onSubmit={onSubmit}>
                <Input onChange={onChange} name="email" value={email} placeholder="Email" type="text" required/>
                <Input onChange={onChange} name="password" value={password} placeholder="Password" type="password" required/>
                <Input type="submit" value={isLoading ? "Loading..." : "Log in"}/>
            </Form>
            {error !== "" ? <Error>{error}</Error> : null}
            <Switcher>
                    Don't have an account? <Link to="/create-account">Create one &rarr;</Link>
            </Switcher>
            <GithubButton />
        </Wrapper>
    )
}

로그인 성공 시 alert창과 함께 JSESSIONID의 쿠키가 들어온 것을 확인할 수 있다. 


회원가입

 현재 로그인은 postman으로 등록한 유저의 정보로 로그인 중이었다. 이제 회원가입 로직도 추가해 웹에서 회원가입과 로그인을 모두 할 수 있도록 해보자.

 우선 이전에 만들어 두었던 컨트롤러를 조금 수정했다.

@PostMapping("/user/signIn")
    public ResponseEntity<String> signIn(@RequestBody MemberSignInRequestDto member) throws IOException {
        log.info("sign in call");
        memberService.save(member);
        return new ResponseEntity<>("save ok", HttpStatus.OK);
    }

memberSignInRequestDto를 이용해 username, email, password를 입력받는다. 그리고 서비스로 이동해 저장을 진행한다.

 

MemberService.java

package com.example.nweeter_backend.service;

import com.example.nweeter_backend.dto.MemberSignInRequestDto;
import com.example.nweeter_backend.modle.Member;
import com.example.nweeter_backend.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    @Transactional
    public void save(MemberSignInRequestDto member) throws IOException {
        Optional<Member> foundName = memberRepository.findByUsername(member.getUsername());
        if(foundName.isPresent()) {
            throw new IOException("사용된 이름 입니다.");
        }
        Optional<Member> foundEmail = memberRepository.findByEmail(member.getEmail());
        if(foundEmail.isPresent()) {
            throw new IOException("사용된 email 입니다.");
        }
        Member saveMember = new Member();
        String password = member.getPassword();
        String encPassword = passwordEncoder.encode(password);
        saveMember.setUsername(member.getUsername());
        saveMember.setPassword(encPassword);
        saveMember.setEmail(member.getEmail());
        memberRepository.save(saveMember);
    }
}

 넘어온 이름과 이메일의 유효성을 검사하고 중복이 있다면 오류를 보내고, 이상이 없다면 유저의 정보를 저장해 준다. 암호는 반드시 암호화해야지만 저장이 가능하다. 

 

예외처리를 위한 GlobalExceptionHandler도 만들어 주었다.

package com.example.nweeter_backend.handler;

import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;

@ControllerAdvice
@RestController
public class GlobalExceptionHandler {

    @ExceptionHandler(value=Exception.class)
    public ResponseEntity<String> handlerArgumentException(Exception e) {
        return new ResponseEntity<>(e.getMessage(), HttpStatusCode.valueOf(HttpStatus.INTERNAL_SERVER_ERROR.value()));
    }
}

 오류발생 시 오류코드와 메시지를 프런트에 전달해 준다.

 

 이제 react의 회원가입 코드를 살펴보자.

create-account.tsx

import { useState } from "react";
import styled from "styled-components"
import { Link, useNavigate } from "react-router-dom";
import GithubButton from "../components/github-btn";
import axios, { AxiosError } from "axios";

const Wrapper = styled.div`
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 420px;
    padding: 50px 0px;
`;

const Title = styled.h1`
    font-size: 42px;
`;

const Form = styled.form`
    margin-top: 50px;
    margin-bottom: 10px;
    display: flex;
    flex-direction: column;
    gap: 10px;
    width: 100%;
`;

const Input = styled.input`
    padding: 10px 20px;
    border-radius: 50px;
    border: none;
    width: 100%;
    font-size: 16px;
    &[type="submit"]{
        cursor: pointer;
        &:hover {
            opacity: 0.8;
        }
    }
    &.log-in{
        background-color: dodgerblue;
    }
`;

const Error = styled.span`
    color: red;
`;

const Switcher = styled.span`
    margin-top: 20px;
    a{
        color: #1d9bf0;
    }
`;

export default function CreateAccount() {
    const navigate = useNavigate();
    const [isLoading, setLoading] = useState(false);
    const [username, setName] = useState("");
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    const [passwordCheck, setPasswordCheck] = useState("");
    const [error, setError] = useState("");
    const onChange = (e : React.ChangeEvent<HTMLInputElement>) => {
        const {target: { name, value }} = e;
        if (name === "username") {
            setName(value);
        }else if (name === "email") {
            setEmail(value);
        }else if (name === "password") {
            setPassword(value);
        }else if (name === "passwordCheck"){
            setPasswordCheck(value);
        }
    }
    const onSubmit =async (e : React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        setError("");
        if(isLoading || username === "" || email === "" || password === "") return;
        if(password !== passwordCheck) {
            setError("password check");
            return
        }
        console.log(email);
        try{
            const response = await axios.post("http://localhost:8080/api/user/signIn", 
                {
                    username,
                    password,
                    email               
                },
                {withCredentials : true});
            
            console.log(response);

            if(response.status == 200) {
                alert("회원가입 완료");
                setLoading(false);
                navigate("/");
            }
            
        }catch(e) {
            if(e instanceof AxiosError){
                console.log(e.response);
                setError(e.response?.data);
            }
        }finally {
            setLoading(false);
        }
    }
    return (
        <Wrapper>
            <Title>Join X</Title>
            <Form onSubmit={onSubmit}>
                <Input onChange={onChange} name="username" value={username} placeholder="Name" type="text" required/>
                <Input onChange={onChange} name="email" value={email} placeholder="Email" type="email" required/>
                <Input onChange={onChange} name="password" value={password} placeholder="Password" type="password" required/>
                <Input onChange={onChange} name="passwordCheck" value={passwordCheck} placeholder="Password check" type="password" required/>
                <Input type="submit" value={isLoading ? "Loading..." : "Create Account"}/>
            </Form>
            {error !== "" ? <Error>{error}</Error> : null}
            <Switcher>
                Already have an account? <Link to="/login">Log in &rarr;</Link>
            </Switcher>
            <GithubButton />
        </Wrapper>
    )
}

 axios 통신을 이용해 post로 usrename, email, password를 백엔드로 보내주고, 간단한 유효성 체크(빈값 및 패스워드 일치)도 넣어 주었다. 회원가입 및 같은 아이디로 가입 시도 시에 오류가 잘 나오는지 체크해 주었다.

 오류 메시지도 잘 뜨는 것을 볼 수 있다. DB도 체크해 보면

정보가 잘 입력된 것을 볼 수 있다. 회원가입된 유저로 로그인 시도를 해보자.

잘 작동한다.


Protected Route

 마지막으로 이전에 해주었던 protected- route 작업을 해주어야 한다. 로그인 상태가 아니라면 home과 profile 페이지에 접근이 불가능하도록 하고 "/login" 페이지로 리다이렉트 해주고 재 로그인 시켜주어야 한다.

 기존에는 firebase의 auth항목을 통해서 쉽게 구현했지만, spring으로는 권한(로그인)이 필요한 컨트롤러를 만들어서 get요청을 보내고, 권한이 있다면 유저의 정보를 반환해 주는 api를 만들어 로그인의 유무를 판별하도록 구현했다. 그리고 응답을 받은 username은 클라이언트의 세션스토리지에 저장해 주도록 한다.

@GetMapping("/user/check")
public ResponseEntity<String> loginCheck(@AuthenticationPrincipal PrincipalDetails principalDetails) {
    log.info("log in check call ");
    return new ResponseEntity<>(principalDetails.getMember().getUsername() , HttpStatus.OK);
}

 MemberApiController에 get요청의 컨트롤러를 하나 만들어 주었다. 시큐리티에서 기본적으로 막혀있기 때문에 권한이 있어야지만 get요청의 응답을 받을 수 있다. @AuthenticationPrincipal 어노테이션을 이용해 현재 로그인되어 있는 유저의 username과 http상태를 응답해 준다.

 

protected-rout.tsx

import axios, { AxiosError } from "axios";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";

export default function ProtectedRoute({children} : {children:React.ReactNode}) {
    const navigate = useNavigate();
    const [isLoading, setLoading] = useState(true);
    const loginCheck = async ()=> {
        try{
            const response = await axios.get("http://localhost:8080/api/user/check",{withCredentials : true})
            if(response.status === 200) {
                console.log("auth data : ", response.data);
                window.sessionStorage.setItem("username", response.data);
                setLoading(false);
                return children;
            }
        }catch(e) {
            if(e instanceof AxiosError){
                console.log(e.message);
                window.sessionStorage.removeItem("username");
                navigate("/login");
                return;
            }
        }
    }
    useEffect(()=>{loginCheck()}, []);

    if(!isLoading) {
        return children;
    }
}

 기존의 auth를 체크해 로그인 유무를 확인하던 코드에서 위에서 만들어둔 api로 요청을 보내고 응답을 받는다면 적절한 하위 페이지로 이동하고, 그렇지 않다면 로그인을 위한 로그인 페이지로 이동하도록 해준다. 

 로그인이 되지 않은 상태에서 home으로 이동 시 빈 화면이 나오며, 다시 login 페이지로 이동한다.

 

 로그인이 완료된 상태이면 home의 화면이 정상적으로 로딩되고, session storage에 username의 정보가 입력된다.

반응형