개인프로젝트/기능프로그램_오늘뭐입지

20240515_SpringSecurity + JJWT 적용

일일일코_장민기 2024. 5. 15. 23:34
728x90

 

 

 

 

 

 

 

 

이전 팀프로젝트에서 작업했었던 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

 

Spring Security의 동작 원리 + JWT + Oauth2까지

1. 클라이언트가 어플리케이션에 요청(JSP에서 로그인) 1.1 AJAX를 통해 1차 검증 - ajax 컨트롤러 확인 1.2 서블릿필터에 의해 시큐리티필터로 시큐리티 작업이 위임 - mainLogin에서 login post로 요청 1.2.1

minkee95.tistory.com

 

사실 팀프로젝트 때와 눈에 띄게 다른 것은 별로 없다.

기껏해야 오래된 코드를 최신 코드로 바꾼다던지(문법적인 부분), 조금 더 코드를 깔끔하게 작성했다던지 하는 부분 밖에 없다...

하지만 새로운 프로젝트에 적용시키는게 오래 걸리다 보니 결국 오늘 꼬박 하루를 썼다.

 

아무튼 다음 작업은 기존 로그인 컨트롤러 / 서비스 / 리포지토리를 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 등을 사용하게 될 텐데, 에러페이지가 없으면 냅다 화이트 라벨이 튀어나온다

로그인 시에만 사용할 수 있는 페이지

이런 문제를 방지하기 위해 에러페이지 구현이 필요할 것이다.