[Backend] SpringSecurity + JWT + 소셜로그인 + 회원가입 (1)
Spring Security
- Spring 기반의 Application의 보안을 담당하는 스프링 하위 프레임워크로 '인증' 및 '권한'에 대한 부분을 Filter 흐름에 따라 처리한다.
- 보안과 관련해서 여러가지 기능들을 쉽게 사용할 수 있도록 제공해줘서 개발자에게 편하다는 장점이 있다.
📑 프로젝트 구현 내용
이번 프로젝트를 통해서 내가 구현한 로그인 시스템은 아래와 같다.
- Member와 MemberInfo로 분리되어 유저의 세부정보는 같이 입력받지만 다른 테이블에 저장함.
- 이메일로 회원가입 및 로그인 시에 JWT Access Token과 RefreshToken 반환
- 소셜로그인의 경우 최초 로그인이면 추가적인 정보 기입을 위해 세션에 소셜계정 정보 저장하여 추가정보 기입 페이지로 이동시켜줌.
- 최초로그인이 아니면 바로 JWT 발급
- 소셜로그인시 이미 사용중인 이메일이라면 이메일 혹은 다른 소셜로그인을 통해 로그인 하도록 함.
📘 코드구조
< 정리해서 올릴 예정>
📘 코드설명
JwtProvider.java
@Component
@RequiredArgsConstructor
public class JwtProvider {
private final String secretKey="c88d74ba-1554-48a4-b549-b926f5d77c9e";
private final long accessExpireTime = 60 * 60 * 1000L;
private final long refreshExpireTime = 60 * 360 * 1000L;
private MemberRepository memberRepository;
public String createAccessToken(Long memberId){
Date expiration = new Date();
expiration.setTime(expiration.getTime() + accessExpireTime);
Map<String, Object> headers = new HashMap<>();
headers.put("type", "token");
Map<String, Object> payloads = new HashMap<>();
payloads.put("memberId", memberId);
String jwt = Jwts
.builder()
.setHeader(headers)
.setClaims(payloads)
.setIssuedAt(new Date())
.setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
return jwt;
}
public Map<String, String> createRefreshToken(Long memberId){
Date expiration = new Date();
expiration.setTime(expiration.getTime() + refreshExpireTime);
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH);
String refreshTokenExpirationAt = simpleDateFormat.format(expiration);
Map<String, Object> headers = new HashMap<>();
headers.put("type", "token");
Map<String, Object> payloads = new HashMap<>();
payloads.put("memberId", memberId);
String jwt = Jwts
.builder()
.setHeader(headers)
.setClaims(payloads)
.setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
Map<String, String> result = new HashMap<>();
result.put("refreshToken", jwt);
result.put("refreshTokenExpirationAt",refreshTokenExpirationAt);
return result;
}
// 토큰 유효성 검사
public Authentication getAuthentication(String token) {
Optional<Member> member = memberRepository.findById(this.getMemberId(token));
UserDetails userDetails = new PrincipalDetails(member.get());
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public Long getMemberId(String token) {
return (Long)Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().get("memberId");
}
public String resolveToken(HttpServletRequest request){
return request.getHeader("token");
}
public boolean validateJwtToken(ServletRequest request, String authToken) {
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(authToken);
return true;
} catch (MalformedJwtException e) {
request.setAttribute("exception", "MalformedJwtException");
} catch (ExpiredJwtException e) {
request.setAttribute("exception", "ExpiredJwtException");
} catch (UnsupportedJwtException e) {
request.setAttribute("exception", "UnsupportedJwtException");
} catch (IllegalArgumentException e) {
request.setAttribute("exception", "IllegalArgumentException");
}
return false;
}
}
- createAccessToken(Long memberId) & createRefreshToken(Long memberId) : AccessToken과 RefreshToken을 각각 생성하여 넣어주는 로직
- getAuthentication(String token) : 토큰이 유효한지 검사해주는 로직으로 토큰을 복호화하여 ID값으로 유저가 있는지 없는지 검사 후 있으면 검증된 유저임을 리턴해준다.
- getMemberId(String token) : 토큰을 복호화하여 ID값 리턴해주는 로직
- resolveToken(request) : request 앞에 Header에 있는 토큰을 가지고오는 로직
- validateJwtToken : token의 값이 유효한지 기간이 지나지는 않았는지 등을 판단하는 로직
JwtAuthenticationFilter.Java
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtProvider jwtProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
String token = jwtProvider.resolveToken((HttpServletRequest) request);
if(token != null && jwtProvider.validateJwtToken(request, token)){
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
- doFilter : 사실상 jwt 실사용시 검증을 해주는 로직으로 토큰이 올바르면 권한이 있다고 설정해주는 필터.
MemberController.Java
@RestController
public class MemberController {
@Autowired
MemberService memberService;
@PostMapping("/signup")
@ResponseBody
public ApiResponse signUp(@RequestBody SignUpRequestDTO signUpRequestDTO) throws ParseException {
return memberService.memberInsert(signUpRequestDTO);
}
@PostMapping("/login")
@ResponseBody
public ApiResponse login(@RequestBody @Validated LoginRequestDTO loginRequestDTO){
return memberService.login(loginRequestDTO);
}
@PostMapping("/newtoken")
@ResponseBody
public ApiResponse newAccessToken(@RequestBody @Validated NewTokenRequestDTO newTokenRequestDTO, HttpServletRequest request) {
return memberService.newAccessToken(newTokenRequestDTO, request);
}
}
MemberService.Java
@RequiredArgsConstructor
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final MemberInfoRepository memberInfoRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final AuthenticationManager authenticationManager;
private final JwtProvider jwtProvider;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
public ApiResponse memberInsert(SignUpRequestDTO signUpRequestDTO) throws ParseException {
ResponseMap result = new ResponseMap();
String encPassword = bCryptPasswordEncoder.encode(signUpRequestDTO.getPassword());
Member member = memberRepository.save(Member.builder()
.email(signUpRequestDTO.getEmail())
.nickName(signUpRequestDTO.getNickName())
.role(Role.MEMBER)
.pwd(encPassword)
.status(Status.YES)
.providerType(ProviderType.EMAIL)
.build());
memberInfoRepository.save(MemberInfo.builder()
.member(member)
.gender(signUpRequestDTO.getGender())
.name(signUpRequestDTO.getName())
.birth(signUpRequestDTO.getBirth())
.point(0)
.imageLink(signUpRequestDTO.getImageLink())
.score(0)
.build());
Map createToken = createTokenReturn(member.getId());
result.setMessage("MEMBER INSERT SUCCESS");
result.setResponseData("accessToken", createToken.get("accessToken"));
result.setResponseData("refreshToken", createToken.get("refreshToken"));
return result;
}
public ApiResponse login(LoginRequestDTO loginRequestDTO) {
ResponseMap result = new ResponseMap();
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginRequestDTO.getEmail(), loginRequestDTO.getPassword())
);
Member member = memberRepository.findByEmail(loginRequestDTO.getEmail());
Map createToken = createTokenReturn(member.getId());
result.setMessage("LOGIN SUCCESS");
result.setResponseData("accessToken", createToken.get("accessToken"));
result.setResponseData("refreshToken", createToken.get("refreshToken"));
} catch (Exception e) {
e.printStackTrace();
throw new AuthenticationException(ErrorCode.UsernameOrPasswordNotFoundException);
}
return result;
}
public ApiResponse newAccessToken(NewTokenRequestDTO newTokenRequestDTO, HttpServletRequest request){
ResponseMap result = new ResponseMap();
String refreshToken = newTokenRequestDTO.getToken();
// AccessToken은 만료되었지만 RefreshToken은 만료되지 않은 경우
if(jwtProvider.validateJwtToken(request, refreshToken)){
Long memberId = jwtProvider.getMemberId(refreshToken);
Map createToken = null;
try {
createToken = createTokenReturn(memberId);
} catch (ParseException e) {
e.printStackTrace();
}
result.setResponseData("accessToken", createToken.get("accessToken"));
result.setResponseData("refreshToken", createToken.get("refreshToken"));
}else{
// RefreshToken 또한 만료된 경우는 로그인을 다시 진행해야 한다.
result.setResponseData("code", ErrorCode.ReLogin.getCode());
result.setResponseData("message", ErrorCode.ReLogin.getMessage());
result.setResponseData("HttpStatus", ErrorCode.ReLogin.getStatus());
}
return result;
}
private Map<String, String> createTokenReturn(Long memberId) throws ParseException {
Map result = new HashMap();
String accessToken = jwtProvider.createAccessToken(memberId);
String refreshToken = jwtProvider.createRefreshToken(memberId).get("refreshToken");
SimpleDateFormat fm = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date expiredAt = fm.parse(jwtProvider.createRefreshToken(memberId).get("refreshTokenExpirationAt"));
RefreshToken insertRefreshToken = RefreshToken.builder()
.token(refreshToken)
.expiredAt(expiredAt)
.build();
refreshTokenRepository.save(insertRefreshToken);
result.put("accessToken", accessToken);
result.put("refreshToken", insertRefreshToken.getToken());
return result;
}
}
- memberInsert : 회원가입 로직으로 Member와 MemberInfo 둘다에 데이터를 넣어주며 Access, Refresh 토큰을 반환한다. 비밀번호 설정 시에 BcryptPasswordEncoder를 사용하여 암호화하여 DB에 저장한다.
- login : 로그인 로직으로 로그인 성공시 Access, Refresh 토큰을 반환한다. 이 때 authenticationManger가 이메일과 비밀번호를 통해 알맞은 로그인인지 검증해준다.
- newAccessToken: accessToken이 만료되고 RefreshToken은 만료되지 않은 경우 둘다 갱신해준다.
- createTokenReturn : Access, Refresh를 둘 다 실직적으로 만들어주는 로직.
📖 참고
- https://deeplify.dev/back-end/spring/oauth2-social-login#buildgradle-%EC%9D%98%EC%A1%B4%EC%84%B1
[Spring Boot] OAuth2 소셜 로그인 가이드 (구글, 페이스북, 네이버, 카카오)
스프링부트를 이용하여 구글, 페이스북, 네이버, 카카오 OAuth2 로그인 구현하는 방법에 대해서 소개합니다.
deeplify.dev
- https://jojoldu.tistory.com/170
Spring Security & 구글 OAuth & 테스트코드로 진행하는 계정 권한 관리 - 2
1-4. 로그인 세션 관리 OAuth2를 사용한다고해서 기존과 다른 마법같은 일이 펼쳐지는것은 아닙니다. OAuth2는 사용자 인증 및 허가된 정보를 가져오는 것외에는 사용하지 않습니다. 인증된 정보를
jojoldu.tistory.com
- https://www.youtube.com/watch?v=VDyY0RZAYtE&list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&index=27
Spirng Security + Jwt 로그인 적용하기
프로젝트를 진행하면서 Spring Security + Jwt를 이용한 로그인을 구현하게 되었다. 목차 Spring Security JWT Spring SEcurity + JWT Spring Security > 가장먼저 스프링 시큐리티에 대해서 알아보자. Sprin
velog.io
- https://mangkyu.tistory.com/76
[SpringBoot] Spring Security란?
대부분의 시스템에서는 회원의 관리를 하고 있고, 그에 따른 인증(Authentication)과 인가(Authorization)에 대한 처리를 해주어야 한다. Spring에서는 Spring Security라는 별도의 프레임워크에서 관련된 기능
mangkyu.tistory.com