2023년 1월 1일
08:00 AM
Buffering ...

최근 글 👑

Spring 인증 및 관리 시스템

2024. 9. 6. 12:09ㆍ스프링

하루에 4개씩 기초지식!

더보기

1. Spring Filter와 Intercepter의 사용예시

  • 필터는 말 그대로 요청과 응답을 거른 뒤 정제하는 역할을 한다!

  • 스프링 컨테이너가 아닌 톰캣과 같은 웹 컨테이너에 의해 관리가 되는 것이고, 스프링 범위 밖에서 처리된다!

  • Dispatcher Servlet에 요청이 전달되기 전 / 후에 url 패턴에 맞는 모든 요청에 대해 부가 작업을 처리할 수 있는 기능을 제공한다!

  • 사용 사례

    • 보안 및 인증 / 인가 관련 작업

    • 모든 요쳉에 대한 로깅 또는 검사

    • 이미지 / 데이터 압축 및 문자열 인코딩

    • Spring과 분리되어야 하는 기능
  • 인터셉터는 요청에 대한 작업 전/ 후로 가로채 요청과 응답을 참조하거나  가공하는 역할을 합니다.

  • 웹 컨테이너에서 동작하는 필터와 달리 인터셉터는 스프링 컨텍스트에서 동작합니다.

  • Dispatcher Servlet이 Controller를 호출하기 전 / 후에 인터셉터가 끼어들어 요청과 등답을 참조하거나 가공할 수 있는 기능을 제공한다!

  • 사용 사례
    • 세부적인 보안 및 인증 / 인가 공통 작업

    • API 호출에 대한 로깅 또는 검사

    • Controller로 넘겨주는 정보(데이터)의 가공


2. 관점지향 프로그래밍이란?

 

  • AOP(Aspect Oriented Programming)는 핵심 비즈니스 로직에 있는 공통 관심사항을 분리하여 각각을 모듈화 하는 것을 의미하며 공통 모듈인 인증, 로깅, 트랜잭션 처리에 용이합니다

  • 핵심 비즈니스 로직에 부가기능을 하는 모듈이 중복되어 분포되어 있을 경우 사용할 수 있습니다.

  • AOP의 가장 큰 특징이자 장점은 중복 코드 제거, 재활용성의 극대화, 변화 수용의 용이성이 좋다는 점이다!

3. Lombok 넌 뭐냐..

 

  • Lombok은 메소드를 컴파일하는 과정에 개입해서 추가적으로 코드를 만들어 냅니다. 이것을 어노테이션 프로세싱이라고 하는데, 어노테이션 프로세싱은 자바 컴파일러가 컴파일 단계에서 어노테이션을 분석하고 처리하는 기법을 말합니다.

 


4. Servlet 이란?

 

  • 클라이언트의 요청을 처리하고, 그 결과를 반환하는 Servlet 클래스의 구현 규칙을 지킨 자바 웹 프로그래밍 기술이다!

  • Spring MVC에서 Controller로 이용되며ㅡ 사용자의 요청을 받아 처리한 후에 결과를 반환합니다.

  • 간단히 - 자바를 사용해 웹을 만들기 위해 필요한 기술.

JWT Token을 사용해서 회원가입 / 로그인/ 회원 탈퇴를 만들어보자!

1. Entity

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users") 
public class UserEntity extends Timestamped { 

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "userId")
    private Long userId;
    @Column(nullable = false, name = "username")
    private String username;
    @Column(nullable = false, unique = true, name = "email" )
    private String email;
    @Column(nullable = false, name = "password")
    private String password;
    @Column(nullable = false, name = "gender")
    @Enumerated(EnumType.STRING)
    private Gender gender;

    @Column(nullable = true, name = "introduce")
    private String introduce;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Status status = Status.ACTIVE;

    // 친구관련 추가(진호)
    @OneToMany (mappedBy = "requestedUserId", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<FriendRequest> friendRequests = new ArrayList<>();
    @OneToMany (mappedBy = "id", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Friend> friends = new ArrayList<>();

    public UserEntity(String username, String email, String password, Gender gender) {
        this.username = username;
        this.email = email;
        this.password = password;
        this.gender = gender;
        this.status = Status.ACTIVE;
    }

    public UserEntity(String email) {
        this.email = email;
    }

    public enum Gender {
        MALE, FEMALE
    }

    public enum Status{
        ACTIVE, WITHDRAWN
    }
}

 

 

2. Repository

@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
    Optional<UserEntity> findByEmail(String email);

    boolean existsByEmail(String email);

    Optional<UserEntity> findByUserId(Long otherUserId);
}




3. Service

@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {
    private final UserRepository userRepository;
    private final JwtUtil jwtTokenUtil;
    private final PasswordEncoder passwordEncoder;
    private final UserValidationUtil userValidationUtil;

    public String userRegiser(UserRegisterRequestDto userRegisterRequestDto) {
        //이메일 중복 여부 확인
        userValidationUtil.checkEmailDuplication(userRegisterRequestDto.getEmail(), userRepository);
        //이메일 형식 검증
        EmailValidator emailValidator = new EmailValidator();
        emailValidator.validateEmail(userRegisterRequestDto.getEmail());
        //비밀번호 형식 검증
        PasswordValidator passwordValidator = new PasswordValidator();
        passwordValidator.validatePassword(userRegisterRequestDto.getPassword());
        //비밀번호 암호화 저장
        String encodePassword = passwordEncoder.encode(userRegisterRequestDto.getPassword());
        UserEntity user = new UserEntity(
                userRegisterRequestDto.getUsername(),
                userRegisterRequestDto.getEmail(),
                encodePassword,
                userRegisterRequestDto.getGender()
        );
        UserEntity saveUser = userRepository.save(user);
        return jwtTokenUtil.generateToken(saveUser.getUserId(), saveUser.getEmail());
    }

    public String loginUser(LoginRequestDto loginRequestDto) {
        UserEntity user = userRepository.findByEmail(loginRequestDto.getEmail())
                .orElseThrow(() -> new IllegalArgumentException("해당 계정의 유저는 없습니다."));
        return jwtTokenUtil.generateToken(user.getUserId(), user.getEmail());
    }
}

 

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final UserValidationUtil userValidationUtil;
    private final PasswordEncoder passwordEncoder;

    //자신의 프로필 조회
    public UserEntity getMe(Long userId) {
        return userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
    }

    // 다른 유저 프로필 조회
    public UserEntity getUserByEmail(String email, String loginEmail) {
        // 사용자가 자신의 프로필을 조회하려 할 때 예외 처리
        if (loginEmail.equals(email)) {
            throw new IllegalArgumentException("본인 프로필 정보는 '자신의 프로필 조회' 탭에서 조회 가능합니다.");
        }

        // 다른 사용자의 이메일을 통해 조회
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
    }

    public void updateUser(Long userId, Map<String, Object> updateFields) {
        UserEntity user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
        //UpdateUtil에서 수정을 원하는 필드만 수정하게 만들어줌
        UpdateUtil.updateUserFields(user, updateFields, passwordEncoder);
        // 변경된 사용자 정보 저장
        userRepository.save(user);
    }

    public void withdrawUser(String email, String password) {
        //이메일 조회
        UserEntity user = userRepository.findByEmail(email).orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
        //탈퇴 여부 확인
        if (user.getStatus() == UserEntity.Status.WITHDRAWN) {
            throw new IllegalArgumentException("이미 탈퇴한 사용자입니다.");
        }
        //비밀번호 일치 확인
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
        }
        //탈퇴 상태로 변경
        user.setStatus(UserEntity.Status.WITHDRAWN);
        userRepository.save(user);
    }
}

 

4. Controller

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AuthController {

    public final AuthService authService;

    // 회원가입
    @PostMapping("/register")
    public ResponseEntity<Void> userRegister(@RequestBody UserRegisterRequestDto userRegisterRequestDto) {
        String bearerToken = authService.userRegiser(userRegisterRequestDto);
        return ResponseEntity
                .ok()
                .header(HttpHeaders.AUTHORIZATION, bearerToken)
                .build();
    }

    // 로그인
    @PostMapping("/login")
    public ResponseEntity<Void> loginUser(@RequestBody LoginRequestDto loginRequestDto) {
        String bearerToken = authService.loginUser(loginRequestDto);
        return ResponseEntity
                .ok()
                .header(HttpHeaders.AUTHORIZATION, bearerToken)
                .build();
    }
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    //자신의 프로필 조회
    @GetMapping("/me")
    public ResponseEntity<UserEntity> getMe (@Auth AuthUser authUser){
        Long userId = authUser.getUserId();
        UserEntity user = userService.getMe(userId);
        return ResponseEntity.ok(user);
    }

    //다른 사람의 프로필 조회
    //모든 사람이 확인하면 안되므로 토큰으로 인증된(로그인된) 사용자만 확인할 수 있도록함
    @GetMapping("/otherUser")
    public ResponseEntity<UserEntity> getOtherUser (@Auth AuthUser authUser, @RequestParam String email){
        // JWT 토큰에서 로그인한 사용자의 이메일 추출
        String loginEmail = authUser.getEmail();
        // 다른 사용자의 정보를 이메일로 조회
        UserEntity otherUser = userService.getUserByEmail(email, loginEmail);

        // 로그인한 사용자와 다른 사용자일 때 민감한 정보 제거
        otherUser.setPassword(null);  // 비밀번호는 노출되지 않게 null 처리

        return ResponseEntity.ok(otherUser);
    }

    @PutMapping("/update")
    public ResponseEntity<String> updateUser(
            @Auth AuthUser authUser, // AuthUser에서 userId 추출
            @RequestBody Map<String, Object> updateFields) {

        Long userId = authUser.getUserId();  // userId 추출
        userService.updateUser(userId, updateFields);  // userId와 updateFields 전달

        return ResponseEntity.ok("회원 정보가 수정되었습니다.");
    }


    @DeleteMapping("/withdraw")
    public ResponseEntity<String> withdrawUser (@RequestBody WithdrawRequestDto withdrawRequestDto){
        try {
            userService.withdrawUser(withdrawRequestDto.getEmail(), withdrawRequestDto.getPassword());
            return ResponseEntity.ok("회원 탈퇴가 완료되었습니다");
        }
        //회원 탈퇴 시
        catch (IllegalArgumentException e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }
}


5. RequestDto

@Getter
public class LoginRequestDto {
    private String email;
    private String password;
}

@Getter
public class UserRegisterRequestDto {

    private String username;
    private String email;
    private String password;
    private UserEntity.Gender gender;

    public UserRegisterRequestDto(  // 어노테이션 추가
            @JsonProperty("username") String username,
            @JsonProperty("email") String email,
            @JsonProperty("password") String password,
            @JsonProperty("gender") UserEntity.Gender gender
    ) {
        this.username = username;
        this.email = email;
        this.password = password;
        this.gender = gender;
    }
}

@Getter
public class WithdrawRequestDto {

    private String email;
    private String password;

    public WithdrawRequestDto(String email, String password) {
        this.email = email;
        this.password = password;
    }
}

@Getter
@Setter
public class UserRequestDto {
    private String userName;
    private String email;
    private String currentPassword;
    private String newPassword;

    public UserRequestDto() {
    }

    public UserRequestDto(String userName, String email, String currentPassword, String newPassword) {
        this.userName = userName;
        this.email = email;
        this.currentPassword = currentPassword;
        this.newPassword = newPassword;
    }
}


6. ResponseDto

@Getter
public class LoginResponseDto {

    private String messege;
    private String token;
}

@Getter
public class UserRegisterResponseDto {

    private final Long id;
    private final String username;
    private final String email;
    private final UserEntity.Gender gender;
    private final LocalDateTime createdAt;

    public UserRegisterResponseDto(Long id, String username, String email, UserEntity.Gender gender, LocalDateTime createdAt) {
        this.id = id;
        this.username = username;
        this.email = email;
        this.gender = gender;
        this.createdAt = createdAt;
    }
}

@Getter
public class UserResponseDto {
    private Long userId;
    private String userName;
    private String email;
    private String gender;
    private String introduce;
    private LocalDateTime createdAt;
    private LocalDateTime modifiedAt;

    public UserResponseDto() {

    }

    public UserResponseDto(Long userId, String userName, String email, String gender, String introduce, LocalDateTime createdAt, LocalDateTime modifiedAt) {
        this.userId = userId;
        this.userName = userName;
        this.email = email;
        this.gender = gender;
        this.introduce = introduce;
        this.createdAt = createdAt;
        this.modifiedAt = modifiedAt;
    }
}


7.Util

@Slf4j(topic = "JwtTokenUtil")
@Component
public class JwtUtil {
    private static final String BEARER_PREFIX = "Bearer ";
    private static final long TOKEN_TIME = 60 * 60 * 1000L;

    private String secretKey = "===========";
    private Key key;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }
    public String generateToken(Long userId, String email) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId))
                        .claim("email", email)
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date) // 발급일
                        .signWith(SignatureAlgorithm.HS256, key) // 암호화 알고리즘
                        .compact();
    }
    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        log.error("Not Found Token");
        throw new NullPointerException("Not Found Token");
    }
    public Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

@Component
public class UpdateUtil {

    public static void updateUserFields(UserEntity user, Map<String, Object> updateFields, PasswordEncoder passwordEncoder) {
        if (updateFields.containsKey("email")) {
            String newEmail = (String) updateFields.get("email");
            user.setEmail(newEmail);
        }

        // 비밀번호 수정 시 조건 추가
        if (updateFields.containsKey("currentPassword") && updateFields.containsKey("newPassword")) {
            String currentPassword = (String) updateFields.get("currentPassword");
            String newPassword = (String) updateFields.get("newPassword");
            if (!passwordEncoder.matches(currentPassword, user.getPassword())) {
                throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다.");
            }
            if (passwordEncoder.matches(newPassword, user.getPassword())) {
                throw new IllegalArgumentException("새 비밀번호는 현재 비밀번호와 동일할 수 없습니다.");
            }
            user.setPassword(passwordEncoder.encode(newPassword));
            updateFields.remove("currentPassword");
            updateFields.remove("newPassword");
        }

        if (updateFields.containsKey("username")) {
            String newUsername = (String) updateFields.get("username");
            user.setUsername(newUsername);
        }

        if (updateFields.containsKey("gender")) {
            UserEntity.Gender newGender = UserEntity.Gender.valueOf((String) updateFields.get("gender"));
            user.setGender(newGender);
        }

        if (updateFields.containsKey("introduce")) {
            String newIntroduce = (String) updateFields.get("introduce");
            user.setIntroduce(newIntroduce);
        }
    }

}

@Component
public class UserValidationUtil {
    //이메일 중복 확인 메서드
    public void checkEmailDuplication(String email, UserRepository userRepository) {
        if(userRepository.existsByEmail(email)){
            throw new IllegalArgumentException("해당 이메일은 이미 사용중입니다.");
        }
    }

    //로그인 시 아이디ㅣ 비밀번호 일치 여부 확인
    public UserEntity findByEmailValidateLogin(String email, String password, UserRepository userRepository) {
        Optional<UserEntity> userOptional = userRepository.findByEmail(email);
        if (userOptional.isEmpty()) {
            throw new IllegalArgumentException("Invalid email or password");
        }

        return userOptional.get();
    }
}

 

8. Filter


@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {

    private final JwtUtil jwtUtil;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String url = httpRequest.getRequestURI();

        if (url.startsWith("/api/register") || url.startsWith("/api/login")) {
            chain.doFilter(request, response);
            return;
        }

        String bearerJwt = httpRequest.getHeader("Authorization");


        if (bearerJwt == null || !bearerJwt.startsWith("Bearer ")) {
            // 토큰이 없는 경우 400을 반환합니다.
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
            return;
        }

        String jwt = jwtUtil.substringToken(bearerJwt);

        try {
            // JWT 유효성 검사와 claims 추출
            Claims claims = jwtUtil.extractClaims(jwt);


            // 사용자 정보를 ArgumentResolver 로 넘기기 위해 HttpServletRequest 에 세팅
            httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
            httpRequest.setAttribute("email", claims.get("email", String.class));

            chain.doFilter(request, response);
        } catch (SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
        } catch (Exception e) {
            log.error("JWT 토큰 검증 중 오류가 발생했습니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT 토큰 검증 중 오류가 발생했습니다.");
        }
    }
}

 

9. config

public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null;
        boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class);

        // @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생
        if (hasAuthAnnotation != isAuthUserType) {
            throw new IllegalArgumentException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다.");
        }

        return hasAuthAnnotation;
    }

    @Override
    public Object resolveArgument(
            @Nullable MethodParameter parameter,
            @Nullable ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            @Nullable WebDataBinderFactory binderFactory
    ) {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

        // JwtFilter 에서 set 한 userId, email 값을 가져옴
        Long userId = (Long) request.getAttribute("userId");
        String email = (String) request.getAttribute("email");


        return new AuthUser(userId, email);
    }
}


@Configuration
@RequiredArgsConstructor
public class FilterConfig {
    private final JwtUtil jwtTokenUtil;

    @Bean
    // jwtfilter를 등록하기 위해서 bean 생성
    public FilterRegistrationBean<JwtFilter> jwtFilter() {
        FilterRegistrationBean<JwtFilter> registrationBean = new FilterRegistrationBean<>();
        //등록할 필터로 jwtfilter 설정
        registrationBean.setFilter(new JwtFilter(jwtTokenUtil));
        //필터가 적용될 URL 설정
        registrationBean.addUrlPatterns("/*");
        return registrationBean;
    }
}


@Component
public class PasswordEncoder {

    //비밀번호를 해싱하여 암호화
    public String encode(String rawPassword) {
        return BCrypt.withDefaults().hashToString(BCrypt.MIN_COST, rawPassword.toCharArray());
    }

    //입력한 비밀번호가 암호화된 비밀번호랑 일치하는지 확인
    public boolean matches(String rawPassword, String encodedPassword) {
        BCrypt.Result result = BCrypt.verifyer().verify(rawPassword.toCharArray(), encodedPassword);
        return result.verified;
    }
}


@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    // ArgumentResolver 등록
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthUserArgumentResolver());
    }
}