생각정리/사이드 프로젝트

사이드 프로젝트 : 중고 거래 플랫폼 - 회원가입 , 로그인 구현

생각중임 2024. 1. 17. 10:05

SpringSecurity를 의존성 주입하여 인증, 인가처리를 해주고 토큰을 쿠키에 넣어 인증처리를 한다.

// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'

// JWT
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

추가할 클래스별 역할

WebSecurityConfig

  • 빈객체에 추가할 인증 및 인가에 관한 설정들을 모아둔 클래스
  • 기존 필터에 필요한 설정 및 만든 필터를 연결해 관리를 할 수 있다.
  • 인증처리 유무를 설정할 수 있다.

JwtAuthenticationFilter

  • UsernamePasseordAuthenticationFilter를 상속받아 구현한 로그인 관련 검증 필터
  • 로그인 인증용 토큰 객체를 생성 및 로그인 성공 여부에 따른 처리 구현

JwtAuthorizationFilter

  • OncePerRequestFilter를 상속받아 구현한 토큰 검증 필터
  • JWT를 이용해 권한을 검증해 접근 여부를 판단해 처리한다.

JwtUtil

  • JWT를 사용하기 위해 설정한 클래스
  • 토큰을 생성하고 검증하기 위해 사용할 함수들을 만들어두고 리턴한다.

UserDetailsImpl

  • Spring Security에서 사용자의 정보를 담는 인터페이스를 구현한 클래스
  • 사용자의 권한, 비밀번호, 계정 고윳값 등을 리턴한다.

UserDetailsServiceImpl

  • Spring Security에서 유저의 정보를 가져오는 인터페이스를 구현한 클래스
  • 조회에 성공하면 UserDetails 타입의 객체를 리턴한다.

구현 코드

WebSecurityConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig {

    private final UserDetailsServiceImpl userDetailsService;
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JwtUtil jwtUtil;

    // 비밀번호 암호화
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // AuthenticationManager 등록
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }


    // 로그인 필터
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
        filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
        return filter;
    }

    // 토큰 검증 필터
    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() {
        return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
    }

    // 필터 연결
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // CSRF 설정
        http.csrf((csrf) -> csrf.disable());

        // 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
        http.sessionManagement((sessionManagement) ->
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );
        http.authorizeHttpRequests((authorizeHttpRequests) ->
                authorizeHttpRequests
                        .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                        .requestMatchers("/api/auth/**").permitAll() // '/api/auth/'로 시작하는 요청 모두 접근 허가
                        .requestMatchers(HttpMethod.GET, "/api/board/**").permitAll() // '게시물 조회 요청 모두 접근 허가
                        .anyRequest().authenticated() // 그 외 모든 요청 인증처리
        );

        // 필터 관리
        http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

JwtAuthenticationFilter

@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
        setFilterProcessesUrl("/api/auth/login");
    }

    // 로그인 토큰 생성
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        try {
            LoginRequestDto loginRequestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);

            return getAuthenticationManager().authenticate(
                    new UsernamePasswordAuthenticationToken(
                            loginRequestDto.getUserId(),
                            loginRequestDto.getPassword(),
                            null
                    )
            );

        } catch (IOException e) {
            e.printStackTrace();
        }


        return super.attemptAuthentication(request, response);
    }

    // 로그인 성공 시
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        String userId = ((UserDetailsImpl)authResult.getPrincipal()).getUsername();
        UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getUser_role();

        String accessToken = jwtUtil.createAccessToKen(userId, role);
        jwtUtil.addJwtToCookie(accessToken, response);

        response.setContentType("application/json; charset=utf-8");
        MessageResponseDto message = new MessageResponseDto("로그인 성공하였습니다." , HttpStatus.OK.value());
        ObjectMapper objectMapper = new ObjectMapper();
        response.getWriter().write(objectMapper.writeValueAsString(message));

    }

    // 로그인 실패 시
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        log.error("로그인 실패");
        response.setStatus(401);
        response.setContentType("application/json; charset=utf-8");
        MessageResponseDto message = new MessageResponseDto("로그인 실패하였습니다." , HttpStatus.UNAUTHORIZED.value());
        ObjectMapper objectMapper = new ObjectMapper();
        response.getWriter().write(objectMapper.writeValueAsString(message));
    }
}

JwtAuthorizationFilter

@RequiredArgsConstructor
@Slf4j
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;

    // JWT를 이용한 필터 검증
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String accessToken = jwtUtil.getJwtFromRequest(request);

        if(StringUtils.hasText(accessToken)) {

            accessToken = jwtUtil.subStringToken(accessToken);

            if(!jwtUtil.validateToken(accessToken)) {
                log.error("토큰정보가 일치하지 않습니다.");
                return;
            }

            Claims info = jwtUtil.getUerInfoFromToken(accessToken);

            try{
                setAuthentication(info.getSubject());
            }catch (Exception e) {
                log.error(e.toString());
                return;
            }
        }

        filterChain.doFilter(request, response);
    }

    // 권한 인증 처리
    public void setAuthentication(String username) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        Authentication authentication = createAuthentication(username);
        context.setAuthentication(authentication);
        SecurityContextHolder.setContext(context);
    }

    // 인증 객체 생성
    private Authentication createAuthentication(String username) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, null,userDetails.getAuthorities());
    }
}

JwtUtil

secretkey는 자신이 사용할 암호를 base64 형식으로 인코딩하여 공유가 안 되는 yml이나 properties에 숨겨서 사용하면 된다.

@Component
@Slf4j
public class JwtUtil {

    // 토큰 해더 키값
    public static final String AUTHORIZATION_HEADER = "Authorization";

    // 사용자 권한 값의 키
    public static final String AUTHORIZATION_KEY = "auth";

    // 토큰 식별자
    public static final String BEARER_PREFIX = "Bearer ";

    // 토큰 유효 시간
    private final long TOKEN_TIME = 60 * 60 * 1000L; //60분

    // 암호화 시킨 사용할 키
    @Value("${jwt.secret.key}")
    private String secretKey;

    // 암호화 키
    private Key key;

    // 암호화 알고리즘
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    // 암호화 키 세팅
    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }


    // 토큰생성
    public String createAccessToKen(String userId, UserRoleEnum role) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(userId) // 사용자 식별아이디
                        .claim(AUTHORIZATION_KEY, role) //유저 권한정보
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME)) //만료시간
                        .setIssuedAt(date) //발급일
                        .signWith(key, signatureAlgorithm)  // 암호화 알고리즘
                        .compact();  //끝
    }

    // 쿠키에 jwt 저장
    public void addJwtToCookie(String token, HttpServletResponse response) {
        try{
            token = URLEncoder.encode(token, "UTF-8").replaceAll("\\+", "%20");
            Cookie accessCookie = new Cookie(AUTHORIZATION_HEADER, token);
            accessCookie.setPath("/");
            response.addCookie(accessCookie);
        }catch (UnsupportedEncodingException e) {
            log.error(e.toString());
        }
    }

    // 토큰 자르기
    public String subStringToken(String tokenValue) {
        if(StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }

        throw new NullPointerException("유효하지 않은 토큰입니다.");
    }


    // 토큰 유효성 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    // 토큰 사용자 정보 가져오기
    public Claims getUerInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }

    // 쿠키에서 토큰값 가져오기
    public String getJwtFromRequest(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();

        if(cookies != null) {
            for (Cookie cookie : cookies) {
                if(cookie.getName().equals(AUTHORIZATION_HEADER)) {
                    try {
                        return URLDecoder.decode(cookie.getValue(), "UTF-8");
                    } catch (UnsupportedEncodingException e) {
                        log.error(e.toString());
                        return null;
                    }
                }
            }
        }

        return null;
    }
}

UserDetailsImpl

@Getter
@RequiredArgsConstructor
public class UserDetailsImpl implements UserDetails {

    private final User user;

    // 계정의 권한 목록 리턴
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        UserRoleEnum role = user.getUser_role();
        String authority = role.getAuthority();

        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(simpleGrantedAuthority);

        return authorities;
    }

    // 계정의 비밀번호 리턴
    @Override
    public String getPassword() {
        return user.getUser_pw();
    }

    // 계정의 고유값 리턴 (중복이 없는 계정의 식별자로 사용할 것으로 선정)
    @Override
    public String getUsername() {
        return user.getUser_id();
    }

    // 계정의 만료 여부 리턴 (true 만료 안됨)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정의 잠김 여부 리턴 (true 잠기지 않음)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // 비밀번호 만료 여부 리턴 (true 만료 안됨)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 계정의 활성화 여부 리턴 (true 활성화 됨)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

UserDetailsServiceImpl

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    // 유저 정보를 리턴
    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        User user = userRepository.findUserByUserId(userId).orElseThrow(() -> new UsernameNotFoundException("해당 유저를 찾을 수 없습니다. " + userId));
        return new UserDetailsImpl(user);
    }
}

 

기본적으로 검증을 할 수 있는 부분들을 구현을 하고 다시 진행 흐름을 이해할 수 있도록 반복을 하였다.
리프레시토큰과 Oauth는 기본적인 API를 구현을 하고 보안강화 목적으로 추가를 하면서 진행해 볼 예정이다.