이전 팀프로젝트에서 작업했었던 SpringSecurity 및 JJWT를 이번에는 개인프로젝트에 적용시키기로 했다.
이유
1. 손쉬운 보안 작업
물론 PostgreSQL에서 작업하거나, Seed 암호를 쓸 수는 있겠지만 그보다 더 쉽게 암호화 및 보안 작업이 구현 가능하다.
2. 세션을 사용하지 않아 사용자가 많은 경우에도 서버의 부담 경감 + 확장 가능성 고려
3. 소셜 로그인을 구현할 경우, 개발자 부담이 대폭 경감
4. 기타 로그인과 관련된 다양한 기능을 쉽고 빠르게 구현할 수 있음
ex) 정지된 유저 처리, 유저 권한 관리, 특정 상태의 유저에 대한 로그인 조치 등
5. 공부했던 내용 복습 + 업그레이드
그래서 구현하긴 했는데...솔직히 예전에 만들었던 코드를 보고 따라 만든 부분이 많다...여전히 어렵긴 하다
전체 코드
build.gradle
//SpringSecurity
implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-security'
//jjwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
application.properties
jwt.expiredMs=jwt만료시간(ms)
spring.jwt.secret=시크릿 코드
SpringSecurityConfig
package org.member.springSecurity.system;
import lombok.RequiredArgsConstructor;
import org.member.springSecurity.jjwt.JJWTFilter;
import org.member.springSecurity.jjwt.JJWTUtil;
import org.member.springSecurity.login.LoginFilter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SpringSecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final JJWTUtil jjwtUtil;
//기간 만료 설정
@Value("${jwt.expiredMs}") String expiredMs;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//httpBasic 및 csrf 사용 중지
http
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable);
//로그인 처리
http
.formLogin(formLogin -> formLogin.loginPage("/login"))
.addFilterBefore(new JJWTFilter(jjwtUtil), LogoutFilter.class)
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jjwtUtil, Long.parseLong(expiredMs)), UsernamePasswordAuthenticationFilter.class);
/**
* 소셜 로그인 위치
*
*
*
*/
// 권한에 따른 인가 설정
http
.authorizeHttpRequests((authorizeRequests) -> authorizeRequests.anyRequest().permitAll());
// 세션 설정
http
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 로그아웃
http
.logout(logout -> logout.logoutUrl("/logout")
.invalidateHttpSession(true)
.deleteCookies("AuthToken")
.logoutSuccessUrl("/LogoutPage")
.permitAll());
return http.build();
}
}
SpringSecurityService
package org.member.springSecurity.system;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.member.MemberDTO;
import org.member.MemberVO;
import org.member.memberController.MemberRepository;
import org.springframework.beans.BeanUtils;
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;
@Slf4j
@RequiredArgsConstructor
@Service
public class SpringSecurityService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
MemberVO memberVO = memberRepository.findByUsername(username);
MemberDTO memberDTO = new MemberDTO();
BeanUtils.copyProperties(memberVO, memberDTO);
log.info("로그인 유저 정보 {}", memberDTO);
return new SpringSecurityUser(memberDTO);
}
}
SpringSecurityUser
package org.member.springSecurity.system;
import lombok.RequiredArgsConstructor;
import org.member.MemberDTO;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
@RequiredArgsConstructor
public class SpringSecurityUser implements UserDetails {
private final MemberDTO memberDTO;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add((GrantedAuthority) () -> "ROLE_USER");
return collection;
}
@Override
public String getPassword() {
return memberDTO.getPassword();
}
@Override
public String getUsername() {
return memberDTO.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
LoginFilter
package org.member.springSecurity.login;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.member.springSecurity.jjwt.JJWTUtil;
import org.member.springSecurity.system.SpringSecurityUser;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
import java.util.Collection;
import java.util.Iterator;
@Slf4j
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JJWTUtil jjwtUtil;
private final Long expiredMs;
@Override
public Authentication attemptAuthentication(final HttpServletRequest request, final HttpServletResponse response) {
String username = request.getParameter("username");
String password = request.getParameter("password");
log.info("Attempting to authenticate user: " + username);
log.info("Attempting to authenticate password: " + password);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
return authenticationManager.authenticate(authRequest);
}
@Override
protected void successfulAuthentication(final HttpServletRequest request, final HttpServletResponse response, final FilterChain chain, Authentication authResult) throws IOException, ServletException {
SpringSecurityUser springSecurityUser = (SpringSecurityUser) authResult.getPrincipal();
String username = springSecurityUser.getUsername();
Collection<? extends GrantedAuthority> authorities = springSecurityUser.getAuthorities();
Iterator<? extends GrantedAuthority> authoritiesIterator = authorities.iterator();
GrantedAuthority grantedAuthority = authoritiesIterator.next();
String role = grantedAuthority.getAuthority();
String token = jjwtUtil.createJwt(username, role, expiredMs);
String cookieValue = "AuthToken=" + token + "; Path=/; HttpOnly";
if (request.isSecure()) {
cookieValue += "; Secure";
}
response.addHeader("Set-Cookie", cookieValue);
response.sendRedirect("/MemberPage");
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, org.springframework.security.core.AuthenticationException failed) throws IOException {
String message = failed.getMessage();
log.error("Unsuccessful authentication: {}", message);
response.sendRedirect("/LoginPage");
}
}
JJWT Filter
package org.member.springSecurity.jjwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.member.MemberDTO;
import org.member.springSecurity.system.SpringSecurityUser;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
@Slf4j
public class JJWTFilter extends OncePerRequestFilter {
private final JJWTUtil jjwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = null;
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("AuthToken".equals(cookie.getName())) {
token = cookie.getValue();
break;
}
}
}
if (token != null && jjwtUtil.validateToken(token)) {
String username = jjwtUtil.getUsername(token);
String country = jjwtUtil.getPassword(token);
String area = jjwtUtil.getRole(token);
MemberDTO memberDTO = new MemberDTO();
memberDTO.setUsername(username);
memberDTO.setCountry(country);
memberDTO.setArea(area);
SpringSecurityUser springSecurityUser = new SpringSecurityUser(memberDTO);
Authentication authentication = new UsernamePasswordAuthenticationToken(springSecurityUser, null, springSecurityUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
JJWT Util
package org.member.springSecurity.jjwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
@Slf4j
@Component
public class JJWTUtil {
private final SecretKey key;
private JJWTUtil(@Value("${spring.jwt.secret}")String secret) {
byte[] byteSecretKey = Decoders.BASE64.decode(secret);
key = Keys.hmacShaKeyFor(byteSecretKey);
}
public String getUsername(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().get("username", String.class);
}
public String getPassword(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().get("password", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwt(String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(key)
.compact();
}
// 토큰의 유효성을 검증하는 메서드
public Boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(key).build().parseClaimsJws(token);
return !isExpired(token); // 토큰 만료 여부 확인
} catch (Exception e) {
return false; // 유효하지 않은 토큰으로 간주
}
}
}
중간에 많은 과정이 있지만 직접 구현한 부분만 표현하면 대충 이런 과정을 거친다.
jsp(로그인 전) --> LoginFilter(attemptAuthentication메서드) --> SpringSecurityService --> LoginFilter(successfulAuthentication메서드) --> jsp(로그인 후)
자세한 구동
https://minkee95.tistory.com/294
사실 팀프로젝트 때와 눈에 띄게 다른 것은 별로 없다.
기껏해야 오래된 코드를 최신 코드로 바꾼다던지(문법적인 부분), 조금 더 코드를 깔끔하게 작성했다던지 하는 부분 밖에 없다...
하지만 새로운 프로젝트에 적용시키는게 오래 걸리다 보니 결국 오늘 꼬박 하루를 썼다.
아무튼 다음 작업은 기존 로그인 컨트롤러 / 서비스 / 리포지토리를 SpringSecurity에 맞추어 필요없는 코드는 덜어내는 작업이다.
1. 회원가입 시, 비밀번호에 암호화
- SpringSecurity를 사용하여 로그인하면 자체적으로 match 작업을 하기 때문에 암호화를 통해 DB에 비밀번호를 넣어주지 않으면 로그인이 되지 않는다
2. 로그인 / 로그아웃 코드 삭제
3. 겸사겸사 코드 정리
그래서 크게 바뀐 코드는 없다
전체코드
컨트롤러
package org.member.memberController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.member.MemberDTO;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.ModelAndView;
import java.sql.Timestamp;
import java.time.Instant;
@Slf4j
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
//회원가입
@PostMapping("/create")
public ModelAndView createMember(MemberDTO createMemberData) throws Exception{
//아이디 중복 검사
if (memberService.existsByUsername(createMemberData.getUsername())) {
throw new Exception();
}
//이메일 중복 검사
if (memberService.existsByEmail(createMemberData.getEmail())) {
throw new Exception();
}
//회원가입 진행
createMemberData.setPassword(passwordEncoder.encode(createMemberData.getPassword()));
Instant registerDate = Timestamp.from(Instant.now()).toInstant();
createMemberData.setRegisterdate(registerDate);
log.info("Create member: " + createMemberData);
memberService.createMember(createMemberData);
ModelAndView view = new ModelAndView();
view.addObject("message", "회원가입 성공");
view.setViewName("Member/LoginPage");
return view;
}
//회원 이메일 변경 업데이트
@PostMapping("/updateMember")
public void updateMember(Long id, String email) throws Exception {
//이메일 중복 검사
if (!memberService.existsByEmail(email)) {
throw new Exception();
}
//데이터 변경 진행
memberService.updateMember(id, email);
}
//회원 삭제
@PostMapping("/deleteMember")
public void deleteMember(Long id) {
memberService.deleteMember(id);
}
}
서비스
package org.member.memberController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.member.MemberVO;
import org.member.MemberDTO;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.util.Optional;
@RequiredArgsConstructor
@Slf4j
@Service
public class MemberService {
private final MemberRepository memberRepository;
//회원가입
public void createMember(MemberDTO createMemberDataDTO) {
MemberVO member = new MemberVO();
BeanUtils.copyProperties(createMemberDataDTO, member);
log.info("Member: {}", member);
memberRepository.save(member);
}
//회원 이메일 정보 변경
public void updateMember(Long id, String email) {
MemberVO updateMember = memberRepository.findById(id).orElseThrow(() -> new RuntimeException("Member not found"));
updateMember.setEmail(email);
memberRepository.save(updateMember);
}
//회원 삭제
public void deleteMember(Long id) {
memberRepository.deleteById(id);
}
//검사용 코드
//아이디 중복 검사
public boolean existsByUsername(String username) {
return memberRepository.existsByUsername(username);
}
//이메일 중복 검사
public boolean existsByEmail(String email) {
return memberRepository.existsByEmail(email);
}
}
리포지토리
package org.member.memberController;
import org.member.MemberDTO;
import org.member.MemberVO;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface MemberRepository extends JpaRepository<MemberVO, Long> {
MemberVO findByUsername(String username);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
// MemberDTO findByEmail(String email);
}
이 다음 작업은 로그인 후에 더이상 세션을 사용하지 않기 때문에 Principal을 사용하여 정보를 불러오고 유저 정보를 사용할 수 있도록 코드를 변경하는 작업을 진행했다.
전체코드
컨트롤러
package org.member.common;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.member.UseMemberDataDTO;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.ModelAndView;
import java.security.Principal;
@Slf4j
@RequiredArgsConstructor
@Controller
public class CommonClothController {
private final PrincipalService principalService;
@GetMapping("/MemberPage")
public ModelAndView MemberPage(Principal principal) {
UseMemberDataDTO useMemberDataDTO = principalService.findByUsername(principal.getName());
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("useMemberDataDTO", useMemberDataDTO);
modelAndView.setViewName("Member/MemberPage");
return modelAndView;
}
@PostMapping("/MyClothUpdatePage")
public ModelAndView MyClothUpdatePage(Principal principal) {
UseMemberDataDTO useMemberDataDTO = principalService.findByUsername(principal.getName());
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("useMemberDataDTO", useMemberDataDTO);
modelAndView.setViewName("Member/MyClothUpdatePage");
return modelAndView;
}
}
서비스
package org.member.common;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.member.MemberVO;
import org.member.UseMemberDataDTO;
import org.member.memberController.MemberRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Slf4j
@Service
public class PrincipalService {
private final PrincipalRepository principalRepository;
public UseMemberDataDTO findByUsername(String username) {
MemberVO memberVO = principalRepository.findByUsername(username);
UseMemberDataDTO useMemberDataDTO = new UseMemberDataDTO();
BeanUtils.copyProperties(memberVO, useMemberDataDTO);
return useMemberDataDTO;
}
}
리포지토리
package org.member.common;
import org.member.MemberVO;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface PrincipalRepository extends JpaRepository<MemberVO, Long> {
MemberVO findByUsername(String username);
}
username만으로 불러오는 코드가 유저 컨트롤러에 있긴 하지만, 깔끔하게 정리하고 싶어서 Principal을 쓰는 컨트롤러부터 리포지토리는 따로 만들었다.
볼만한 부분은 Principal을 사용하는 부분이다.
public ModelAndView MemberPage(Principal principal) {
UseMemberDataDTO useMemberDataDTO = principalService.findByUsername(principal.getName());
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("useMemberDataDTO", useMemberDataDTO);
modelAndView.setViewName("Member/MemberPage");
return modelAndView;
}
여기서 pricipal.getName()하면 유저가 입력한 ID가 출력된다.
이 ID를 통해 유저 정보를 불러오고, 그 중에서 실제로 프론트에서 사용할 정보만 DTO에 담아서 프론트로 전송한다.
프론트가 뭐 바뀐 건 딱히 없기 때문에 볼 건 없다.
그냥 쿠키가 들어간 걸 보면서 좋아하면 될듯
개선점
- 에러페이지 구현: 스프링시큐리티에서 발생하는 에러(억지로 로그인 요청을 하는 등)의 경우에 커스텀된 loginFailHandler 등을 사용하게 될 텐데, 에러페이지가 없으면 냅다 화이트 라벨이 튀어나온다
이런 문제를 방지하기 위해 에러페이지 구현이 필요할 것이다.
'개인프로젝트 > 기능프로그램_오늘뭐입지' 카테고리의 다른 글
20240517_Kafka 사용하기 (0) | 2024.05.17 |
---|---|
20240516_ChatGPT 질문 만들기 + MSA 통합(1) (0) | 2024.05.16 |
20240515_유저 DB 조정 (0) | 2024.05.15 |
20240514_날씨 모듈 통합 (1) | 2024.05.15 |
20240514_미세먼지 파트 조정 (0) | 2024.05.14 |