오늘 우리는 Spring Boot 및 React를 이용하여 사용자 인증을 구현하는 방법을 살펴보겠습니다. 이 포스트에서는, 우리는 회원가입 기능을 구현하고, 중복 이메일을 체크하며, JWT 토큰을 사용하여 사용자 인증을 할 예정입니다.
우리의 백엔드는 Spring Boot를 기반으로 하며, 이는 주로 AuthController와 AuthService 클래스를 사용합니다.
AuthController 클래스는 회원가입에 필요한 요청을 처리합니다. POST 요청으로 사용자 정보를 받아오고, 중복 이메일 확인과 회원가입을 위해 AuthService를 호출합니다.
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthService authService;
private final JwtTokenProvider jwtTokenProvider;
@ValidAspect
@PostMapping("/user")
public ResponseEntity<?> signup(@Valid @RequestBody UserReqDto userReqDto, BindingResult bindingResult) {
authService.checkDuplicatedByEmail(userReqDto.getEmail());
authService.signup(userReqDto);
return ResponseEntity.ok(DataRespDto.ofDefault());
}
}
AuthService는 사용자 인증 서비스를 제공합니다. 사용자의 이메일이 이미 사용 중인지 체크하고 사용자 등록을 처리합니다.
@Service
@RequiredArgsConstructor
public class AuthService implements UserDetailsService, OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final AuthRepository authRepository;
private final UserRepository userRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
public void checkDuplicatedByEmail(String email) {
User userEntity = authRepository.findUserByEmail(email);
if(userEntity != null) {
throw new CustomException("Duplicated Email",
ErrorMap.builder()
.put("email","이미 사용중인 이메일입니다.").build());
}
}
public void signup(UserReqDto userReqDto) {
User userEntity = userReqDto.toEntity();
authRepository.saveUser(userEntity);
authRepository.saveAuthority(
Authority.builder().userId(userEntity.getUserId()).roleId(1).build());
}
}
MyBatis를 사용하여 DB 쿼리를 처리합니다. 이메일을 이용한 사용자 조회, 사용자 정보 저장, 권한 저장 등의 쿼리를 처리합니다.
<?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.korea.triplocation.repository.AuthRepository">
<resultMap type="com.korea.triplocation.domain.user.entity.User" id="userMap">
<id property="userId" column="user_id"/>
<result property="email" column="email"/>
<result property="password" column="password"/>
<result property="name" column="name"/>
<result property="phone" column="phone"/>
<result property="address" column="address"/>
<result property="postsImgId" column="posts_img_id"/>
<result property="createDate" column="create_date"/>
<collection property="authorities" javaType="list" resultMap="authorityMap" />
</resultMap>
<resultMap type="com.korea.triplocation.domain.user.entity.Role" id="roleMap">
<id property="roleId" column="role_id"/>
<result property="roleName" column="role_name"/>
</resultMap>
<resultMap type="com.korea.triplocation.domain.user.entity.Authority" id="authorityMap">
<id property="authorityId" column="authority_id"/>
<result property="userId" column="user_id"/>
<result property="roleId" column="role_id"/>
<association property="role" resultMap="roleMap" />
</resultMap>
<select id="findUserByEmail" resultMap="userMap">
select
ut.user_id,
ut.email,
ut.password,
ut.name,
ut.phone,
ut.address,
ut.posts_img_id,
ut.create_date,
at.authority_id,
at.user_id,
at.role_id,
rt.role_id,
rt.role_name
from
user_tb ut
left outer join authority_tb at on(at.user_id = ut.user_id)
left outer join role_tb rt on(rt.role_id = at.role_id)
where
ut.email = #{email}
</select>
<insert id="saveUser" parameterType="com.korea.triplocation.domain.user.entity.User" useGeneratedKeys="true" keyProperty="userId">
<choose>
<when test="provider != null">
-- 조건에 따라 회원가입 진행 하는 로직입니다 provider가 null 값이 아니면 oauth2의 회원가입에 대한 쿼리
insert into user_tb (email, password, name, phone, address, posts_img_id, create_date, provider)
values (#{email}, #{password}, #{name}, #{phone}, #{address}, #{postsImgId}, now(), #{provider})
</when>
<otherwise>
insert into user_tb (email, password, name, phone, address, posts_img_id, create_date)
values (#{email}, #{password}, #{name}, #{phone}, #{address}, #{postsImgId}, now())
</otherwise>
</choose>
</insert>
<insert id="saveAuthority" parameterType="com.korea.triplocation.domain.user.entity.Authority">
insert into authority_tb
values (0, #{userId}, #{roleId})
</insert>
<update id="updateProvider" parameterType="com.korea.triplocation.domain.user.entity.User">
UPDATE user_tb
SET
provider = #{provider}
WHERE
user_id = #{userId}
</update>
</mapper>
우리의 인증 시스템은 JSON Web Token(JWT)를 이용합니다.
JWT는 두 개체 사이에서 정보를 안전하게 전송하기 위한 컴팩트하고 독립적인 방법입니다.
JwtTokenProvider 클래스는 JWT를 생성하고 검증하는 역할을 담당합니다. 이 클래스에서는 토큰 생성, 토큰 유효성 검사, 토큰에서 인증 정보 추출 등의 메소드를 구현하고 있습니다.
@Component
@Slf4j
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
}
public JwtRespDto generateToken(Authentication authentication) {
StringBuilder builder = new StringBuilder();
authentication.getAuthorities().forEach(authority -> {
builder.append(authority.getAuthority() + ",");
});
builder.delete(builder.length() - 1 , builder.length());
String authorities = builder.toString();
Date tokenExpireDate = new Date(new Date().getTime() + (1000 * 60 * 60 * 24)); //프로젝트 완성후 만료시간 수정하기
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(tokenExpireDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtRespDto.builder().grantType("Bearer").accessToken(accessToken).build();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
// log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
// log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
// log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
// log.info("IllegalArgument JWT Token", e);
} catch (Exception e) {
// log.info("JWT Token Error", e);
}
return false;
}
public String getToken(String token) {
String type = "Bearer ";
if(StringUtils.hasText(token) && token.startsWith(type)) {
return token.substring(type.length());
}
return null;
}
public Claims getClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
public Authentication getAuthentication(String accessToken) {
Authentication authentication = null;
Claims claims = getClaims(accessToken);
if(claims.get("auth") == null) {
throw new CustomException("AccessToken에 관한 정보 없음!");
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
String auth = claims.get("auth").toString();
for(String role : auth.split(",")) {
authorities.add(new SimpleGrantedAuthority(role));
}
UserDetails userDetails = new User(claims.getSubject(), "", authorities);
authentication = new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
return authentication;
}
public String generateOAuth2RegisterToken(Authentication authentication) {
Date tokenExpiresDate = new Date(new Date().getTime() + (1000 * 60 * 10));
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
return Jwts.builder()
.setSubject("OAuth2Register")
.claim("email", email)
.setExpiration(tokenExpiresDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String generateAccessToken(Authentication authentication) {
String email = null;
if(authentication.getPrincipal().getClass() == UserDetails.class) {
// Principal User
PrincipalUser principalUser = (PrincipalUser) authentication.getPrincipal();
email = principalUser.getEmail();
}else {
// Oauth2 User
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
email = oAuth2User.getAttribute("email");
}
if(authentication.getAuthorities() == null) {
throw new RuntimeException("등록된 권한이 없습니다.");
}
StringBuilder roles = new StringBuilder();
authentication.getAuthorities().forEach(authority -> {
roles.append(authority.getAuthority() + ",");
});
roles.delete(roles.length() - 1, roles.length());
Date tokenExpiresDate = new Date(new Date().getTime() + (1000 * 60 * 60 * 24));
String accessToken = "Bearer "+ Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", roles)
.setExpiration(tokenExpiresDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return accessToken;
}
}
이 클래스에서 사용되는 주요 메소드들은 다음과 같습니다:
JwtAuthenticationFilter 클래스는 HTTP 요청에서 JWT를 검증하는 역할을 담당합니다. 이 클래스는 Servlet Filter로서, 모든 요청에 대해 동작하여 JWT를 확인하고 해당 사용자를 인증합니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean{
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String accessToken = httpRequest.getHeader("Authorization");
accessToken = jwtTokenProvider.getToken(accessToken);
boolean validationFlag = jwtTokenProvider.validateToken(accessToken);
if(validationFlag) {
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
JwtAuthenticationEntryPoint 클래스는 인증되지 않은 사용자가 보호된 자원에 액세스하려 할 때 동작합니다. 이 클래스는 인증 실패시 발생하는 예외를 처리하여 적절한 응답을 반환합니다.
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
ErrorRespDto<?> errorResponseDto =
new ErrorRespDto<AuthenticationException>(HttpStatus.INTERNAL_SERVER_ERROR, authException); //임시
ObjectMapper objectMapper = new ObjectMapper();
String responseJson = objectMapper.writeValueAsString(errorResponseDto);
PrintWriter out = response.getWriter();
out.println(responseJson);
}
}
PrincipalUser 클래스는 Spring Security에서 사용하는 사용자의 정보를 담는 객체입니다. 이 클래스는 UserDetails 인터페이스를 구현하여 사용자의 권한 정보와 함께 기본적인 사용자 정보를 저장합니다.
@Builder
@Data
public class PrincipalUser implements UserDetails {
/**
*
*/
private static final long serialVersionUID = -6984381303716862634L;
private int userId;
private String email;
private String password;
private int postsImgId;
private List<Authority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
this.authorities.forEach(authority -> {
authorities.add(new SimpleGrantedAuthority(authority.getRole().getRoleName()));
});
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
User 클래스는 사용자 정보를 표현하는 엔티티입니다. 사용자 ID, 이메일, 비밀번호, 이름, 전화번호, 주소, 게시글 이미지 ID, 제공자 정보 및 계정 생성 날짜 등의 필드를 포함하고 있습니다.
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
private int userId;
// role_id => roleId 수정
private String email;
private String password;
private String name;
private String phone;
private String address;
private int postsImgId;
private String provider;
private LocalDate createDate; // 계정 생성 일자
private List<Authority> authorities;
public PrincipalUser toPrincipal() {
PostsImg postsImg = new PostsImg();
return PrincipalUser.builder()
.userId(userId)
.email(email)
.password(password)
.postsImgId(postsImg.getPostsImgId())
.authorities(authorities)
.build();
}
public UserRespDto toDto() {
PostsImg postsImg = new PostsImg();
return UserRespDto.builder()
.userId(userId)
.email(email)
.name(name)
.phone(phone)
.address(address)
.postsImgId(postsImg.getPostsImgId())
.build();
}
}
Role 클래스는 사용자의 권한을 표현하는 엔티티입니다. 각 사용자는 여러 권한을 가질 수 있으며, 권한은 사용자가 수행할 수 있는 작업을 결정합니다.
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Role {
private int roleId;
private String roleName;
}
Authority 클래스는 사용자의 권한을 나타내는 엔티티입니다. 각 사용자는 여러 권한을 가질 수 있으며, 이 클래스는 사용자 ID와 권한 ID를 연결하는 역할을 합니다. 권한 정보는 별도의 Role 엔티티에서 관리합니다.
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Authority {
private int authorityId;
private int userId;
private int roleId;
private Role role;
}
PostsImg 클래스는 게시글 이미지 정보를 표현하는 엔티티입니다. 이 엔티티는 사용자가 게시글에 업로드한 이미지에 대한 정보를 저장합니다. 이 정보에는 이미지의 원본 이름, 임시 이름, 크기 등이 포함됩니다.
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PostsImg {
private int postsImgId;
private int userId;
private String originName;
private String tempName;
private String imgSize;
}
각 클래스는 특정 작업을 수행하기 위한 메소드를 제공합니다. 예를 들어, User 클래스는 toPrincipal() 메소드를 통해 Spring Security의 PrincipalUser 객체로 변환할 수 있고, toDto() 메소드를 통해 응답 DTO로 변환할 수 있습니다. 이러한 메소드는 서비스 계층에서 해당 엔티티를 다루는 데 사용됩니다.
프론트엔드에서는 React와 Recoil을 사용하여 사용자 인증을 관리합니다.
SignUp 컴포넌트는 사용자로부터 입력 받은 정보를 이용해 회원가입 요청을 보냅니다. 각 필드의 유효성 검사 후, axios.post를 이용해 서버로 회원가입 요청을 보냅니다.
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { Box, Button, FormControl, Grid, InputLabel, MenuItem, Select, TextField, Typography } from '@mui/material';
import axios from 'axios';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {useMutation} from "react-query";
const signupContainer = css`
display: flex;
align-items: center;
justify-content: center;
height: 800px;
`
;
const signupBox = css`
width: 500px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin-top: 8px;
`;
const signupText = css`
margin-top: 80px;
margin-bottom: 30px;
`;
const inputContainer = css`
width: 500px;
`;
const StyleInput = styled(TextField)`
margin-top: 10px;
margin-bottom: 10px;
width: 100%;
`;
const addressForm = css`
width: 100%;
margin-top: 15px;
`;
const errorMsg = css`
margin-left: 5px;
margin-bottom: 20px;
font-size: 12px;
color: red;
`;
const submitButton = css`
height: 45px;
margin-top: 30px;
margin-bottom: 20px;
background-color: #0BD0AF;
color: white;
font-size: 15px;
&:hover {
background-color: #0BAF94;
}
&:active {
background-color: #40D6BD;
}
`;
const address = [
"서울특별시",
"부산광역시",
"인천광역시",
"대구광역시",
"대전광역시",
"광주광역시",
"울산광역시",
"세종특별자치시",
"경기도",
"강원도",
"충청북도",
"충청남도",
"전라북도",
"전라남도",
"경상북도",
"경상남도",
"제주특별자치도"
];
const SignUp = () => {
const navigate = useNavigate();
const [ signupUser, setSignupUser ] = useState({
profileImgPath: '',
email: '',
password: '',
name: '',
phone: '',
address: ''
});
const [ errorMessages, setErrorMessages ] = useState({
profileImgPath: '',
email: '',
password: '',
name: '',
phone: '',
address: ''
});
// 로그인
const onChangeHandler = (e) => {
const { name, value } = e.target;
setSignupUser(
{
...signupUser,
[name]: value
}
)
};
const signupHandleSubmit = async () => {
try {
const option = {
headers: {
'Content-Type': 'application/json'
}
}
const response = await axios.post(`http://localhost:8080/api/v1/auth/user`, signupUser, option);
setErrorMessages({
profileImgPath: '',
email: '',
password: '',
name: '',
phone: '',
address: ''});
const accessToken = response.data.grantType + " " + response.data.accessToken;
localStorage.setItem("accessToken", accessToken);
alert("회원가입 완료");
window.location.replace("/auth/login");
return response;
}catch (error) {
setErrorMessages(error.response.data);
}
}
return (
<Grid component="main" maxWidth="xs" css={signupContainer}>
<Box css={signupBox}>
<Typography component="h1" variant="h5" css={signupText}>
Sign Up
</Typography>
<Box component="form" css={inputContainer}>
<StyleInput
required
id="email"
label="이메일"
placeholder="abc@gmail.com"
name="email"
autoComplete="email"
onChange={onChangeHandler}
autoFocus
/>
<div css={errorMsg}>{errorMessages.email}</div>
<StyleInput
required
id="password"
label="비밀번호"
name="password"
type="password"
autoComplete="current-password"
onChange={onChangeHandler}
/>
<div css={errorMsg}>{errorMessages.password}</div>
<StyleInput
required
id="name"
label="이름"
name="name"
autoComplete="name"
onChange={onChangeHandler}
/>
<div css={errorMsg}>{errorMessages.name}</div>
<StyleInput
required
id="phone"
label="연락처"
placeholder="010-1234-1234"
name="phone"
autoComplete="tel"
onChange={onChangeHandler}
/>
<div css={errorMsg}>{errorMessages.phone}</div>
<Box>
<FormControl css={addressForm}>
<InputLabel id="addressSelectLabel">주소</InputLabel>
<Select
required
labelId="addressSelectLabel"
id="address"
value={signupUser.address}
label="주소"
onChange={(event) => setSignupUser({...signupUser, address: event.target.value})}
>
{address.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<div css={errorMsg}>{errorMessages.address}</div>
<Button css={submitButton}
type='button'
onClick={signupHandleSubmit}
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign Up
</Button>
</Box>
</Box>
</Grid>
);
};
export default SignUp;
AuthRouter 컴포넌트는 사용자의 로그인 상태를 체크하여 인증이 필요한 페이지에 대한 접근을 제어합니다. Recoil을 사용하여 전역 상태를 관리하고, react-query를 이용하여 서버와의 통신을 관리합니다.
import React, {useEffect} from 'react';
import {useRecoilState} from "recoil";
import {useQuery} from "react-query";
import axios from "axios";
import {useNavigate} from "react-router-dom";
import {authenticationState} from "../../../store/atoms/AuthAtoms";
const AuthRouter = ({ path, element }) => {
const navigate = useNavigate();
const [authState, setAuthState] = useRecoilState(authenticationState);
const authenticated = useQuery(["authenticated"], async () => {
const option = {
headers: {
"Authorization": `${localStorage.getItem("accessToken")}`
}
}
return await axios.get('http://localhost:8080/api/v1/auth/authenticated', option)
}, {
onSuccess: (response) => {
if (response.status === 200) {
if(response.data) {
setAuthState(true);
}
}
},
// onError: () => {
// setAuthState(false);
// alert("로그인이 필요한 페이지입니다.")
// navigate("/auth/login")
// }
});
useEffect(() => {
if(authenticated.isSuccess) {
const authenticatedPaths = ['/user', '/contents']
const authPath = '/auth';
console.log(authState)
if(authState && path.startsWith(authPath)) {
navigate("/");
}
if(!authState && authenticatedPaths.filter(authenticatedPath => path.startsWith(authenticatedPath)).length > 0) {
alert("로그인이 필요한 페이지입니다.")
navigate("/auth/login");
}
}
}, [authState, authenticated.isSuccess, path, navigate])
if (authenticated.isLoading) {
return <></>
}
return element;
};
export default AuthRouter;
Spring Boot와 React를 이용하여 사용자 인증 시스템을 구현하는 방법을 살펴보았습니다.
중복 이메일 체크, 회원가입, 그리고 인증 상태 관리 등 기본적인 인증 시스템을 구현하였습니다
회원가입 로직이 완성 되었으니 회원가입된 계정으로 로그인을 해보는
로직을 작성해보도록 하겠습니다!!