Spring&React 추가 세션

박영준·2022년 12월 19일
0

Java

목록 보기
34/112

config

JasyptConfig

package com.sparta.example.config;

import lombok.RequiredArgsConstructor;
import org.jasypt.encryption.StringEncryptor;
import org.jasypt.encryption.pbe.PooledPBEStringEncryptor;
import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 이 클래스는 properties(yaml)파일(민감한 정보가 들어있는 파일)의 암호화 및 복호화를 위한 설정 클래스 입니다.
 * 참고 - https://antstudy.tistory.com/493
 */
@RequiredArgsConstructor
@Configuration
    //Jasypt: 암호화 작동 방식에 대한 깊은 지식 없이도 최소한의 노력으로 자신의 프로젝트에 기본 암호화 기능을 추가할 수 있도록 하는 Java 라이브러리
public class JasyptConfig {

    @Value("${jasypt.encryptor.password}")
    private String encryptKey;

    @Bean("jasyptStringEncryptor")
    public StringEncryptor stringEncryptor() {
        PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();
        SimpleStringPBEConfig config = new SimpleStringPBEConfig();
        config.setPassword(encryptKey);
        config.setAlgorithm("PBEWithMD5AndDES");
        config.setKeyObtentionIterations("1000");
        config.setPoolSize("1");

        config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
        config.setIvGeneratorClassName("org.jasypt.iv.NoIvGenerator");
        config.setStringOutputType("base64");
        encryptor.setConfig(config);
        return encryptor;
    }
}

JpaConfig

package com.sparta.example.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

/**
 * 이 클래스는 jpa 관련 설정을 위한 클래스 입니다.
 */
@EnableJpaAuditing
@Configuration
public class JpaConfig {
}

controller

AuthController

package com.sparta.example.controller;

import com.sparta.example.dto.user.UserDto;
import com.sparta.example.service.AuthService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

/**
 * 세션에서 진행한 api 문서 - https://www.notion.so/API-354f70e9d74a4363900996ee63dc25fb
 */

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/auth")
public class AuthController {
    private final AuthService authService;

    //회원가입
    @PostMapping("/signup")
    //reqeustbody 에서 dto 로 mapping 이 되도록 함
        //@RequestBody: 회원정보를 requestBody 에서 받으므로
        //final: reqDto 에 담긴 값은 변할 일도 없고, 변경 할 것도 아니기 때문에 고정해둠
    public ResponseEntity<?> signup(@RequestBody @Valid final UserDto.SignupReqDto reqDto){
        authService.signup(reqDto);
        //null: AIP 설계에서 Response Body 에 따로 응답값을 설정해두지 않았으므로
        return ResponseEntity.ok(null);
    }

    //로그인
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody final UserDto.LoginReqDto reqDto){

        // Response Header 에 전달 (생성된 토큰을 헤더에 담습니다.)
        String accessToken = authService.login(reqDto);
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(HttpHeaders.AUTHORIZATION, accessToken);

            //httpHeaders: token 이 추가된 header
            //HttpStatus.OK: 상태코드
        return new ResponseEntity<>(null, httpHeaders, HttpStatus.OK);
    }
}

PostController

package com.sparta.example.controller;

import com.sparta.example.dto.post.PostDto;
import com.sparta.example.service.PostService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

/**
 * 세션에서 진행한 api 문서 - https://www.notion.so/API-354f70e9d74a4363900996ee63dc25fb
 */

@RequestMapping("/api/posts")
@RequiredArgsConstructor
@RestController
public class PostController {
    private final PostService postService;

    @PostMapping
    public ResponseEntity<PostDto.DetailResDto> create(@Valid @RequestBody final PostDto.CreateReqDto dto,
                                                       @AuthenticationPrincipal final UserDetails userDetails) {

        PostDto.DetailResDto resDto = postService.create(Long.parseLong(userDetails.getUsername()), dto);

        return ResponseEntity.ok(resDto);
    }

    @GetMapping
    public ResponseEntity<PostDto.AllResDto> readAll() {

        PostDto.AllResDto resDtos = postService.readAll();

        return ResponseEntity.ok(resDtos);
    }

    @GetMapping("/{id}")
    public ResponseEntity<PostDto.DetailResDto> readOneById(@PathVariable("id") final Long id) {

        PostDto.DetailResDto resDto = postService.readOneById(id);

        return ResponseEntity.ok(resDto);
    }

    @PutMapping("/{id}")
    public ResponseEntity<?> update(@PathVariable("id") final Long id,
                                    @Valid @RequestBody final PostDto.UpdateReqDto dto,
                                    @AuthenticationPrincipal final UserDetails userDetails) {

        postService.update(Long.parseLong(userDetails.getUsername()), id, dto);

        return ResponseEntity.ok(null);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<?> delete(@PathVariable("id") final Long id,
                                    @AuthenticationPrincipal final UserDetails userDetails) {

        postService.delete(Long.parseLong(userDetails.getUsername()), id);

        return ResponseEntity.ok(null);
    }
}

dto

common

ErrorResponse

package com.sparta.example.dto.common;

public record ErrorResponse(String msg) {
}

post

PostDto

package com.sparta.example.dto.post;

import com.sparta.example.entity.Post;
import com.sparta.example.entity.User;

import javax.validation.constraints.Size;
import java.util.List;

/**
 * inner class로 구성한 DTO 클래스
 * java 14 이후 추가된 record를 사용
 */
public class PostDto {

    public record CreateReqDto(@Size(min = 2, max = 20, message = "제목은 2자 이상, 20자 이하로 입력해 주세요") String title,
                               @Size(min = 1, max = 255, message = "제목은 1자 이상, 255자 이하로 입력해 주세요") String content) {

        public Post toEntity(User user) {
            return new Post(user, this.title, this.content);
        }

    }

    public record UpdateReqDto(@Size(min = 2, max = 20, message = "제목은 2자 이상, 20자 이하로 입력해 주세요") String title,
                               @Size(min = 1, max = 255, message = "제목은 1자 이상, 255자 이하로 입력해 주세요")String content) {

    }

    public record DetailResDto(Long id, String title, String content, String nickname) {

        public DetailResDto(Post post){
            this(post.getId(), post.getTitle(), post.getContents(), post.getUser().getNickname());
        }
    }

    public record AllResDto(List<SimpleResDto> postList) {

    }

    public record SimpleResDto(Long id, String title, String nickname) {

        public SimpleResDto(Post post) {
            this(post.getId(), post.getTitle(), post.getUser().getNickname());
        }
    }

}

user

UserDto

package com.sparta.example.dto.user;

import com.sparta.example.entity.User;
import com.sparta.example.validation.ValidSignup;

import javax.validation.constraints.NotBlank;

/**
 * inner class로 구성한 DTO 클래스
 * java 14 이후 추가된 record를 사용
 */
public class UserDto {

    @ValidSignup
    public record SignupReqDto(String nickname, String password){
        //toEntity() 메소드: dto(SignupReqDto)를 entity(User)로 변환
        public User toEntity(){
            return new User(this.nickname, this.password);
        }

    }

    public record LoginReqDto(@NotBlank(message = "닉네임을 입력해 주세요") String nickname,
                              @NotBlank(message = "비밀번호를 입력해 주세요") String password){

    }

}

entity

Post

package com.sparta.example.entity;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Post extends TimeEntity {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @Column(nullable = false, columnDefinition = "VARCHAR(20)")
    private String title;

    @Column(nullable = false, columnDefinition = "VARCHAR(255)")
    private String contents;

    @ManyToOne(cascade = CascadeType.PERSIST, fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    public Post(User user, String title, String contents){
        this.title = title;
        this.contents = contents;
        this.user = user;
    }

    public void update(String title, String contents){
        this.title = title;
        this.contents = contents;
    }

    public boolean checkMemberByMemberId(Long memberId) {

        return this.user.getId().equals(memberId);
    }

}

TimeEntity

package com.sparta.example.entity;


import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.sql.Timestamp;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class TimeEntity {

    @CreatedDate
    private Timestamp createdAt;

    @LastModifiedDate
    private Timestamp updatedAt;
}

User

package com.sparta.example.entity;

import com.sparta.example.util.converter.PasswordEncConverter;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "USERS")
public class User extends TimeEntity{

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @Column(unique = true, nullable = false, columnDefinition = "VARCHAR(20)")
    private String nickname;

    //password 저장시 rawpassword를 PasswordEncConverter가 암호화 후 저장
    @Convert(converter = PasswordEncConverter.class)
    @Column(nullable = false, columnDefinition = "VARCHAR(80)")
    private String password;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRole role;

    public User(String nickname, String password){

        this.nickname = nickname;
        this.password = password;
        this.role = UserRole.USER;
    }
}

UserRole

package com.sparta.example.entity;

import lombok.Getter;

@Getter
public enum UserRole {

    USER(Authority.USER);

    private final String authority;

    UserRole(String authority) {
        this.authority = authority;
    }

    public static class Authority {

        private static final String USER = "ROLE_USER";

    }

}

exception

handler

RestControllerExceptionHandler

package com.sparta.example.exception.handler;

import com.sparta.example.dto.common.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.authentication.rememberme.InvalidCookieException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.validation.ConstraintViolationException;
import java.util.List;

@RestControllerAdvice
public class RestControllerExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(code = HttpStatus.BAD_REQUEST)
    public ErrorResponse methodArgumentNotValidException(MethodArgumentNotValidException e) {

        List<ObjectError> allErrors = e.getAllErrors();

        ObjectError objectError = allErrors.get(0);

        String message;

        if(objectError.getClass().equals(FieldError.class)){
            FieldError fieldError = (FieldError) objectError;
            message = fieldError.getField() + " : " + fieldError.getDefaultMessage();
        }else{
            message = objectError.getDefaultMessage();
        }

        //e.printStackTrace();

        return new ErrorResponse(message);
    }

    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse constraintViolationException(ConstraintViolationException e) {

        //e.printStackTrace();

        return new ErrorResponse(e.getMessage());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse notExistException(IllegalArgumentException e) {

        //e.printStackTrace();

        return new ErrorResponse(e.getMessage());
    }
}

repository

PostRepository

package com.sparta.example.repository;

import com.sparta.example.entity.Post;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PostRepository extends JpaRepository<Post, Long> {
}

UserRepository

package com.sparta.example.repository;

import com.sparta.example.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByNickname(String nickname);

    boolean existsByNickname(String nickname);
}

security

config

JwtConfig

package com.sparta.example.security.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.example.security.filter.JwtFilter;
import com.sparta.example.security.jwt.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;

@RequiredArgsConstructor
public class JwtConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private final JwtUtil jwtUtil;
    private final ObjectMapper om;

    @Override
    public void configure(HttpSecurity http) {
        JwtFilter jwtFilter = new JwtFilter(jwtUtil, om);
        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

SecurityConfig

package com.sparta.example.security.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.example.security.exceptionhandler.CustomAccessDeniedHandler;
import com.sparta.example.security.exceptionhandler.CustomAuthenticationEntryPoint;
import com.sparta.example.security.jwt.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtUtil jwtUtil;
    private final ObjectMapper om;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.csrf()
                .disable();

        http
                .authorizeRequests(auth -> auth
                        .antMatchers("/h2-console/**").permitAll()
                        .antMatchers("/api/auth/**").permitAll()
                        .antMatchers(HttpMethod.GET, "/api/posts").permitAll()
                        .antMatchers(HttpMethod.GET,"/api/posts/{\\d+}").permitAll()
                        .anyRequest().authenticated());

        http
                .exceptionHandling()
                .authenticationEntryPoint(customAuthenticationEntryPoint())
                .accessDeniedHandler(customAccessDeniedHandler())
                .and()
                .headers()
                .frameOptions()
                .disable()

                // 1. filterChain() 메소드 내부에 이 설정 추가 --> 이것을 해주지 않으면, 밑의 corsConfigurationSource 가 적용되지 않습니다!
                .and()
                .cors()

                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .apply(new JwtConfig(jwtUtil, om));

        return http.build();
    }

    /**
     * 이 설정을 해주면, 우리가 설정한대로 CorsFilter 가 Security 의 filter 에 추가되어
     * 예비 요청에 대한 처리를 해주는 filter 를 추가
     * cors 개념 참고 - https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-CORS-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-%F0%9F%91%8F
     */
    @Bean
    public CorsConfigurationSource corsConfigurationSource(){

        CorsConfiguration config = new CorsConfiguration();

    //백엔드에서 보내주는 것
        // 서버에서 응답하는 리소스에 접근 가능한 출처를 명시
        // Access-Control-Allow-Origin 헤더
            //config.addAllowedOrigin(로컬 주소 넣기);
        config.addAllowedOrigin("http://charleybucket.s3-website.ap-northeast-2.amazonaws.com"); //요거 변경하시면 됩니다.

        // 특정 헤더를 클라이언트 측에서 꺼내어 사용할 수 있게 지정 (백엔드 쪽에서 꺼낼지 말지 정할 수 있음)
        // 만약 지정하지 않는다면, Authorization 헤더 내의 토큰 값을 사용할 수 없음
        // Access-Control-Expose-Headers
        config.addExposedHeader(JwtUtil.AUTHORIZATION_HEADER);

    //백엔드로 오는 걸 허용해주는 것
        // 예비 요청 후, 본 요청에 허용할 HTTP method 지정 (예비 요청에 대한 응답 헤더에 추가됨)
        // Access-Control-Allow-Methods
        config.addAllowedMethod("*");

        // 본 요청에 허용할 HTTP header(예비 요청에 대한 응답 헤더에 추가됨)
        // Access-Control-Allow-Headers
        config.addAllowedHeader("*");

        // 기본적으로 브라우저에서 인증 관련 정보들을 요청 헤더에 담지 않음
        // 이 설정을 통해서 브라우저에서 인증 관련 정보들을 요청에 담을 수 있도록 해줍니다.
        // Access-Control-Allow-Credentials
        config.setAllowCredentials(true);

        //setAllowCredentials() 메소드 검증
        // allowCredentials 를 true로 하였을 때,
        // allowedOrigin의 값이 * (즉, 모두 허용)이 설정될 수 없도록 검증합니다.
        config.validateAllowCredentials();

        // 어떤 경로에 이 설정을 적용할 지 명시합니다. (여기서는 전체 경로)
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);

        return source;
    }

    @Bean
    public CustomAccessDeniedHandler customAccessDeniedHandler(){
        return new CustomAccessDeniedHandler(om);
    }

    @Bean
    public CustomAuthenticationEntryPoint customAuthenticationEntryPoint(){
        return new CustomAuthenticationEntryPoint(om);
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

exceptionhandler

CustomAccessDeniedHandler

package com.sparta.example.security.exceptionhandler;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.example.dto.common.ErrorResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements org.springframework.security.web.access.AccessDeniedHandler {

    private final ObjectMapper om;

    private static final String CUSTOM_DEFAULT_ERROR_MSG = "권한이 없습니다.";

    private static final String DEFAULT_ERROR_MSG = "접근이 거부되었습니다.";

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {

        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        String errorMsg = accessDeniedException.getMessage().equals(DEFAULT_ERROR_MSG)
                ? CUSTOM_DEFAULT_ERROR_MSG
                : accessDeniedException.getMessage();

        ErrorResponse errorResponse = new ErrorResponse(errorMsg);

        String result = om.writeValueAsString(errorResponse);

        response.getWriter().write(result);

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
    }
}

CustomAuthenticationEntryPoint

package com.sparta.example.security.exceptionhandler;


import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.example.dto.common.ErrorResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper om;

    private static final String DEFAULT_ERROR_MSG = "Full authentication is required to access this resource";
    private static final String CUSTOM_DEFAULT_ERROR_MSG = "로그인이 필요합니다";


    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        String errorMsg = authException.getMessage();

        if(errorMsg.equals(DEFAULT_ERROR_MSG)){
            errorMsg = CUSTOM_DEFAULT_ERROR_MSG;
        }

        ErrorResponse errorResponse = new ErrorResponse(errorMsg);

        String result = om.writeValueAsString(errorResponse);

        response.getWriter().write(result);

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

filter

JwtFilter

package com.sparta.example.security.filter;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.example.dto.common.ErrorResponse;
import com.sparta.example.security.jwt.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.http.HttpMethod;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.rememberme.InvalidCookieException;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final ObjectMapper om;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String token = jwtUtil.extractToken(request.getHeader(JwtUtil.AUTHORIZATION_HEADER));

        if (StringUtils.hasText(token)) {

            try {
                jwtUtil.validateToken(token);
                setAuthentication(token);
                filterChain.doFilter(request, response);
            } catch (InvalidCookieException e) {
                sendErrorMsg(e, response);
            }
        } else {

            filterChain.doFilter(request, response);
        }
    }

    private void setAuthentication(String token) {
        Authentication authentication = jwtUtil.getAuthentication(token);

        SecurityContext securityContext = SecurityContextHolder.getContext();

        securityContext.setAuthentication(authentication);
    }

    private void sendErrorMsg(Exception e, HttpServletResponse response) {
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        ErrorResponse errorResponse = new ErrorResponse(e.getMessage());

        try {
            String result = om.writeValueAsString(errorResponse);
            response.getWriter().write(result);
        } catch (IOException ex) {
            log.error(ex.getMessage(), ex);
        }
    }

}

jwt

JwtUtil

package com.sparta.example.security.jwt;


import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.rememberme.InvalidCookieException;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.security.Key;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;

@Component
@Slf4j
public class JwtUtil {

    public static final String AUTHORIZATION_HEADER = "Authorization";

    public static final String TOKEN_PREFIX = "Bearer ";
    private static final String AUTHORITY_KEY = "auth";

    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분

    private final Key key;

    public JwtUtil(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public String extractToken(String bearerToken){

        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(JwtUtil.TOKEN_PREFIX)){
            return bearerToken.substring(7);
        }

        return null;
    }

    //authentication 인증 객체를 토큰 값으로 바꿔준다.
    public String generateToken(Authentication authentication) {

        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining());

        //access Token 만료 시간 = 현재 시간 + 제한 시간
        Date accessTokenExpiresIn = new Date(new Date().getTime() + ACCESS_TOKEN_EXPIRE_TIME);

        //Access Token 생성
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITY_KEY, authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TOKEN_PREFIX + accessToken;
    }

    public Authentication getAuthentication(String accessToken) {

        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITY_KEY) == null) throw new InvalidCookieException("로그인 정보가 잘못되었습니다. 다시 로그인해주세요.");

        List<SimpleGrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITY_KEY).toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public void validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        } catch (SecurityException | MalformedJwtException e) {
            log.error("잘못된 JWT 서명입니다.", e);
            throw new InvalidCookieException("로그인 정보가 잘못되었습니다. 다시 로그인해주세요.");
        } catch (ExpiredJwtException e) {
            log.error("만료된 JWT 토큰입니다.", e);
            throw new InvalidCookieException("로그인 시간이 만료되었습니다. 다시 로그인해주세요.");
        } catch (UnsupportedJwtException e) {
            log.error("지원되지 않는 JWT 토큰입니다.", e);
            throw new InvalidCookieException("로그인 정보가 잘못되었습니다. 다시 로그인해주세요.");
        } catch (IllegalArgumentException e) {
            log.error("JWT 토큰이 잘못되었습니다.");
            throw new InvalidCookieException("로그인 정보가 잘못되었습니다. 다시 로그인해주세요.");
        }
    }

    private Claims parseClaims(String accessToken) {

        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

service

UserDetailsServiceImpl

package com.sparta.example.security.service;

import com.sparta.example.entity.User;
import com.sparta.example.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.Component;

import java.util.Collections;

@RequiredArgsConstructor
@Component
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        return userRepository.findByNickname(username)
                .map(this::createUserDetails)
                .orElse(null);

    }

    private UserDetails createUserDetails(User user){

        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(user.getRole().toString());

        //userdetails 내부의 username에 userId를 넣어둡니다(DB에 저장된 ID값)
        return new org.springframework.security.core.userdetails.User(
                String.valueOf(user.getId()),
                user.getPassword(),
                Collections.singleton(grantedAuthority));
    }
}

CustomAuthenticationProvider

package com.sparta.example.security;

import com.sparta.example.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

/**
 * 인증 과정을 직접 구현하여 진행합니다.
 */
@RequiredArgsConstructor
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = authentication.getName();
        String password = authentication.getCredentials().toString();

        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        if(userDetails == null){
            throw new UsernameNotFoundException("입력하신 회원 닉네임 정보가 없습니다");
        } else if (!passwordEncoder.matches(password, userDetails.getPassword())) {
            throw new BadCredentialsException("비밀번호가 일치하지 않습니다");
        }

        return new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

service

AuthService

package com.sparta.example.service;

import com.sparta.example.dto.user.UserDto;
import com.sparta.example.repository.UserRepository;
import com.sparta.example.security.jwt.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Transactional
@Service
public class AuthService {

    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final UserRepository userRepository;
    private final JwtUtil jwtUtil;

    //회원가입
    //dto 정보를 받아서
    public void signup(final UserDto.SignupReqDto dto){
        //dto 정보를 entity 로 변환시키고, 저장
            //entity 로 변환 어디서? service 에서 해줘도 되고, dto 에서 해줘도 됨 (여기선 dto 에서 했음)
        userRepository.save(dto.toEntity());
    }
    
    //로그인
    public String login(UserDto.LoginReqDto dto){

        //인증 전 객체
        //인증 객체(인증 토큰)을 직접 만들어 인증을 진행합니다.
        UsernamePasswordAuthenticationToken beforeAuthentication = new UsernamePasswordAuthenticationToken(dto.nickname(), dto.password());

        //인증 후 객체
        //authenticationManager 에게 인증을 요청하고, authenticationManager 가 authenticationProvider 에게 인증을 위임하여,
        //authenticationProvider 에서 인증 진행 후, 인증 완료된 인증 객체를 받습니다.
        Authentication afterAuthentication = authenticationManagerBuilder.getObject().authenticate(beforeAuthentication);

        //인증 완료된 객체로 JWT를 생성합니다.
        return jwtUtil.generateToken(afterAuthentication);
    }
}

PostService

package com.sparta.example.service;

import com.sparta.example.dto.post.PostDto;
import com.sparta.example.entity.Post;
import com.sparta.example.entity.User;
import com.sparta.example.repository.PostRepository;
import com.sparta.example.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Transactional
@Service
public class PostService {
    private final UserRepository userRepository;
    private final PostRepository postRepository;

    public PostDto.DetailResDto create(final Long userId, final PostDto.CreateReqDto dto) {

        User user = getUserByIdIfExists(userId);

        Post post = dto.toEntity(user);

        return new PostDto.DetailResDto(postRepository.save(post));
    }

    @Transactional(readOnly = true)
    public PostDto.AllResDto readAll() {

        List<PostDto.SimpleResDto> simpleResDtos = postRepository.findAll().stream().map(
                PostDto.SimpleResDto::new).collect(Collectors.toList());

        return new PostDto.AllResDto(simpleResDtos);
    }

    @Transactional(readOnly = true)
    public PostDto.DetailResDto readOneById(final Long postId) {

        Post post = getPostByIdIfExists(postId);

        return new PostDto.DetailResDto(post);
    }
    public void update(final Long userId, final Long postId, final PostDto.UpdateReqDto dto) {

        Post post = getPostByIdIfExists(postId);

        throwExceptionIfNotOwner(post, userId);

        post.update(dto.title(), dto.content());

    }
    public void delete(final Long userId, final Long postId) {

        Post post = getPostByIdIfExists(postId);

        throwExceptionIfNotOwner(post, userId);

        postRepository.deleteById(postId);
    }
    private void throwExceptionIfNotOwner(final Post post, final Long memberId) {

        if (!post.checkMemberByMemberId(memberId)) {
            throw new AccessDeniedException("회원님이 작성한 글이 아닙니다.");
        }
    }
    private Post getPostByIdIfExists(final Long postId) {

        return postRepository.findById(postId).orElseThrow(
                () -> new IllegalArgumentException("존재하지 않는 게시글입니다")
        );
    }
    private User getUserByIdIfExists(Long userId) {

        return userRepository.findById(userId).orElseThrow(
                () -> new IllegalArgumentException("존재하지 않는 회원입니다")
        );
    }
}

util

converter

PasswordEncConverter

package com.sparta.example.util.converter;

import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

import javax.persistence.AttributeConverter;
import javax.persistence.Converter;

@Converter
@Component
@RequiredArgsConstructor
public class PasswordEncConverter implements AttributeConverter<String, String> {

    private final PasswordEncoder passwordEncoder;

    @Override
    public String convertToDatabaseColumn(String attribute) {
        return passwordEncoder.encode(attribute);
    }

    @Override
    public String convertToEntityAttribute(String dbData) {
        return dbData;
    }
}

validation

SignupValidator

package com.sparta.example.validation;

import com.sparta.example.dto.user.UserDto;
import com.sparta.example.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;

/**
 * 회원가입시 검증을 진행할 validator
 */
@Component
@RequiredArgsConstructor
public class SignupValidator implements ConstraintValidator<ValidSignup, UserDto.SignupReqDto> {

    private final UserRepository userRepository;

    @Override
    public void initialize(ValidSignup constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(UserDto.SignupReqDto value, ConstraintValidatorContext context) {

        String nickname = value.nickname();
        String password = value.password();

        if(!StringUtils.hasText(nickname)){
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("닉네임을 입력해 주세요").addConstraintViolation();
            return false;
        }else if(!Pattern.matches("(?=.*[a-z])(?=.*\\d)[a-z\\d]{4,10}$" ,nickname)){
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("닉네임은 소문자, 숫자를 필수로 포함한 4-10자로 입력할 수 있습니다").addConstraintViolation();
            return false;
        }else if(userRepository.existsByNickname(nickname)){
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("이미 존재하는 닉네임입니다").addConstraintViolation();
            return false;
        }

        if(!StringUtils.hasText(password)){
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("비밀번호를 입력해 주세요").addConstraintViolation();
            return false;
        }else if(!Pattern.matches("(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*])(?=.*\\d)[a-zA-Z0-9!@#$%^&*]{8,15}$" ,password)){
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("비밀번호는 소문자, 대문자, 숫자, 특수문자(!@#$%^&*)를 필수로 포함한 8-15자로 입력할 수 있습니다").addConstraintViolation();
            return false;
        }
        return true;
    }
}

ValidSignup

package com.sparta.example.validation;

import javax.validation.Constraint;
import java.lang.annotation.*;

@Documented
@Constraint(validatedBy = SignupValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidSignup {

    String message() default "";

    Class[] groups() default {};

    Class[] payload() default {};
}

application.yml

# 분리된 properties 파일을 그룹화 및 실행할 profile 지정
spring:
  profiles:
    active:
      - local
    group:
      local:
        - db-local
        - common-local
      dev:
        - db-dev
        - common-dev
    include:
      - db
      - common

# yml 파일을 둘로 나눈 이유?
  # 1. 개발 환경과 배포 환경이 다른데, 개발 할때마다 매번 yml 을 작성할 수도 없는 노릇. 그래서 미리 분리를 시켜놓고 관리.
  # 2. 하나의 yml 파일에 모든 기능을 넣으면 찾기 어려움. 그래서 쉽게 찾아서 관리 할 수 있도록 분리시킴

application-common.yml

# 공통 적용 부분
  # 깃허브에 올릴 때, 보안 문제 때문에 jwt 가 암호화되도록 만듦
jwt:
  secret: ENC(evMIiXSPQ5odTUYlKFZhBSpXFmwAKAeYx10sX/gfE5vB88tMf7WyYeu4Mx9YC16lpu300wBzW7jW3VJQ3Bo4cWAyWy7qIeNDTslZ73VE5EyJzuGrs2xPYq5TNZP9ap4DsRpltDH11IY=)
---
# local 환경에서 적용
spring:
  config:
    activate:
      on-profile: "common-local"

#HTTP 요청 로그 - 참고 https://soobindeveloper8.tistory.com/765
  # 이걸 넣으면, http method, url, host, header, ... 등 어떤 요청이 들어왔는지 확인 가능하도록 출력된다.
logging:
  level:
    org.apache.coyote.http11: debug
---
# dev 환경에서 적용
spring:
  config:
    activate:
      on-profile: "common-dev"

logging:
  level:
    org.apache.coyote.http11: debug

application-db.yml

# 공통 적용
spring:
  jpa:
    open-in-view: false
    properties:
      hibernate:
        jdbc.batch_size: 100
        default_fetch_size: 100
        order_inserts: true
        order_updates: true

---
# local 환경에서 적용
spring:
  config:
    activate:
      on-profile: "db-local"

  h2:
    console:
      enabled: true
      path: /h2-console

  jpa:
    show-sql: true
    database: H2
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true

  datasource:
    hikari:
      driver-class-name: org.h2.Driver
      jdbc-url: jdbc:h2:mem://localhost/~/board;MODE=MySQL;
      username: sa
      password:
---
# dev 환경에서 적용
spring:
  config:
    activate:
      on-profile: "db-dev"

  jpa:
    show-sql: true
    database: MYSQL
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true

  datasource:
    hikari:
      driver-class-name: com.mysql.cj.jdbc.Driver
      jdbc-url: ENC() # 내부에 여러분의 암호화된 jdbc-url을 넣어주시면 됩니다!
      username: ENC() # 내부에 여러분의 암호화된 username을 넣어주시면 됩니다!
      password: ENC() # 내부에 여러분의 암호화된 password를 넣어주시면 됩니다!
      max-lifetime: 60000
      connection-timeout: 3000
      maximum-pool-size: 10

build.gradle

plugins {
    id 'org.springframework.boot' version '2.7.5'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
    id 'java'
}

group = 'com.sparta'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.4'      //Jasypt 사용을 위해 추가

    implementation 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'

    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
    exclude '**/*'
    useJUnitPlatform()
}
profile
개발자로 거듭나기!

0개의 댓글