Skip to content

masiljangajji/My-Books

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

61 Commits
 
 
 
 
 
 
 
 

Repository files navigation

📖 My-Books

구성원


박민수

신재훈

이담호

이승재

정재현

개발 환경

  • 개발도구: Intellij IDEA - Ultimate
  • 언어: Java 11 LTS
  • 빌드도구: Maven
  • 개발
    • Spring Framework: 5.3
    • Spring Boot: 2.7.18
    • Spring Cloud
      • Spring Cloud Gateway
      • Spring Cloud Netflex(Eureka)
      • Spring Cloud Config
    • Spring Data
      • Spring Data JPA
      • Spring Data Elasticsearch
      • Spring Data Redis
    • Spring Batch
    • Spring Rest Docs
    • JPA
      • QueryDSL
  • 테스트
    • Junit5
    • AssertJ
    • Mockito
    • SonarQube
  • 데이터베이스
    • MySQL: 8.0.25
    • Redis
  • 검색엔진
    • Elastic Search: 7.11.1
  • ERD
    • ERDCloud
  • UI
    • BOOTSTRAP5
    • TOAST UI
  • NHN Cloud
    • Instance
    • Secure Key Manager
    • Object Storage
    • Load Balancer
  • 기타
    • Dooray Hook Sender

사용 기술

Java Apache Maven JWT
Bootstrap5 Thymeleaf JavaScript
Spring Spring Boot Spring Batch Spring Eureka Spring Cloud Gateway
MySQL JPA Hibernate QueryDSL Elasticsearch Redis
GitHub Actions Jenkins Docker Ubuntu Nginx
Git GitHub IntelliJ IDEA DataGrip SonarLint SonarQube

아키텍쳐 구조

322182830-f451aa1e-7312-465c-ab46-01c95a38fe7c

CI/CD

CI_CD

ERD

er1

WBS

  • GitHub Projects의 RoadMap 사용 스크린샷 2024-03-27 00 17 31

테스트 커버리지

  • Resource API
    tc2
  • Authorization API
    td_auth

기능

회원

  • 회원가입 , 수정 , 탈퇴 ,조회
  • 회원가입 시 유효성 검사 및 중복검사 , dooray message hook을 이용한 인증
  • 회원 비밀번호는 BCrypt 를 사용해 암호화 하여 DB에 저장
  • 로그아웃 , 탈퇴 , 비밀번호 변경 , 휴면인증 , 잠금인증
    • Logout Interceptor 동작 , (auth + gateway) redis에 존재하는 리프래시토큰과 유저 아이디 정보 삭제 , Front 쿠키 삭제
  • 회원 등급
    • 등록 , 조회
    • 회원 등급은 추가시 기존의 등급을 자동으로 대체 (기존 등급은 비활성으로 변경)
  • 회원 상태
    • 조회
    • 활성 , 휴면 , 잠금 , 탈퇴 존재
    • 90일간 로그인하지 않을 시 활성상태로 변경 , 계정 탈취시 잠금상태로 변경
    • 활성상태는 Dooray Hook 을 이용한 인증을 이용해 활성상태로 변경 가능
    • 잠금상태는 Dooray Hook 을 이용한 인증 및 비밀번호 변경으로 활성상태로 변경 가능

로그인 시퀀스 다이어그램

일반 회원 로그인을 예시로 그렸습니다. My-Books-로그인 drawio (9)

  • 로그인 절차
    1. Front 에서 이메일과 비밀번호 를 입력
    2. 이메일 인증요청을 보냄
    3. 이메일 인증 성공시 BCrypt로 암호화된 비밀번호를 Front로 응답
    4. 사용자가 입력한 평문 비밀번호를 BCrypt로 암호화된 비밀번호와 검증
    5. 성공시 토큰발급 및 쿠키에 추가
    6. 로그인 포인트 적립,로그인 시간 갱신
    • payco 로그인 절차
      1. 페이코 로그인 api 호출
      2. oauthId 로 최초 로그인 판별
      3. 최초 로그인시
        • 사용자가 정보제공 동의를 한 경우
          • 해당 정보로 회원가입 및 로그인 처리
          • 비밀번호는 dummy 라는 값으로 등록 (로그인시 BCrypt로 검증하기 떄문에 dummy라는 평문으로는 로그인 인증 실패)
        • 사용자가 정보제공 동의를 하지 않은 경우
          • 이메일 , 생일 등의 정보를 입력받는 Form 으로 이동
          • 해당 정보로 회원가입 및 로그인 처리
      4. 최초 로그인이 아닐 시
        • 로그인처리

인증/인가

  • 로그인

    1. 로그인 성공시 Auth 서버 호출

    2. Auth Server JWT 엑세스토큰을 발급 , 토큰 발급시 Redis에 (UUID+ip주소+X-User-Agent , 유저 아이디) 형식으로 저장 , 토큰에는 UUID가 기입

    3. Auth Server Redis 에 (엑세스 토큰+ip주소+X-User-Agent,키메니저가 관리하는 암호값과 ip주소를 BCrypt로 잠근 값) 형식으로 저장 , RefreshToken의 역할을 함

      
        /**
       * methodName : createToken
       * author : masiljangajji
       * description : 로그인시 JWT 엑세스 토큰과 Refresh Token 을 발행함
       * TokenRequest 에 유저의 정보 뿐 아니라 ip , X-User-Agent 의 부가정보도 포함
       * 토큰에 유저 아이디를 기입하지 않기 위해서 페이로드에 UUID 를 넣고 , 그에 매칭되는 userId 정보를 레디스에 삽입함
       * 이 값은 gateway 에서 UUID + ip + X-User-Agent 를 이용해 userId 를 가져올 것임
       * 리프래시 토큰은 JWT 의 형태는 아니고 , 키메니저에서 관리하는 암호값 + ip 를 비크립트로 감싼 값
       * 만약 레디스가 탈취당한다 해도 비크립트로 감싸져있기 떄문에 원문을 알 수 없고 , 키메니저가 관리하는 암호값을 모르기 떄문에 조작이 불가능함
       * 리프래시토큰은 Access Token + ip + X-User-Agent 정보를 이용해 가져올 수 있음
       *
       * @param tokenRequest request
       * @return response entity
       */
       public ResponseEntity<TokenResponse> createToken(
           @RequestBody TokenRequest tokenRequest) {
      
      
       String ipAddress = tokenRequest.getIp();
       String userAgent = tokenRequest.getUserAgent();
      
       redisService.setValues(tokenRequest.getUuid() + ipAddress + userAgent,
               String.valueOf(tokenRequest.getUserId()),
               Duration.ofMillis(jwtConfig.getRefreshExpiration()));
      
       String accessToken = authService.createAccessToken(tokenRequest);
      
       redisService.setValues(accessToken + ipAddress + userAgent,
               passwordEncoder.encode(keyConfig.keyStore(redisConfig.getRedisValue()) + ipAddress),
               Duration.ofMillis(jwtConfig.getRefreshExpiration()));
      
       return new ResponseEntity<>(new TokenResponse(accessToken), HttpStatus.CREATED);
       }
      
    4. 엑세스토큰을 응답으로 Front로 반환

  • 로그아웃

    Front Server Logout Interceptor 동작

        
        
        @Override
        public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                            ModelAndView modelAndView) {
    
            ApplicationContext context = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
            TokenAdaptor tokenAdaptor = Objects.requireNonNull(context).getBean(TokenAdaptor.class);
    
            //리프래시토큰 유저아이디 redis 삭제
            tokenAdaptor.deleteRefreshToken(
                    new LogoutRequest((String) request.getAttribute("identity_cookie_value"), Utils.getUserIp(request),
                            Utils.getUserAgent(request)));
            // 엑세스 토큰 담은 쿠키 삭제
            CookieUtils.deleteJwtCookie(response);
    
    
            // UUID - UserId 담은 redis 삭제 및 admin 쿠키 삭제
            if (Objects.nonNull(request.getAttribute("admin_cookie_value"))) {
                log.debug("어드민쿠키 삭제 시작 ");
                RedisAuthService redisAuthService = context.getBean(RedisAuthService.class);
                redisAuthService.deleteValues((String) request.getAttribute("admin_cookie_value"));
                log.debug("레디스 삭제");
                CookieUtils.deleteAdminCookie(response);
                log.debug("어드민쿠키 삭제 완료");
            }
    
    
        }
        
    

    Auth Server 로그아웃 요청

    
         /**
        * methodName : deleteRefreshToken
        * author : masiljangajji
        * description : 로그아웃 요청이 올 시 레디스에서 유저아이디와 리프래시 토큰의 정보를 삭제함
        * 기존의 엑세스 토큰은 유효하지만 로그아웃시 유저아이디를 담고있는 정보가 사라지기 떄문에
        * gateway 에서 검증시 UUID 에 해당하는 유저아이디 정보를 가져오지 못해 InValid 하다는 판단을 하게 됨
        * 따라서 로그아웃시 기존의 엑세스토큰은 무력화되는 효과를 갖게 됨
        *
        * @param logoutRequest request
        * @return response entity
        */
    
        @DeleteMapping("/logout")
        public ResponseEntity<Void> deleteRefreshToken(@RequestBody LogoutRequest logoutRequest) {
            DecodedJWT jwt = JWT.decode(logoutRequest.getAccessToken());
    
            String ipAddress = logoutRequest.getIp();
            String userAgent = logoutRequest.getUserAgent();
            redisService.deleteValues(logoutRequest.getAccessToken() + ipAddress + userAgent);
            redisService.deleteValues(jwt.getSubject() + ipAddress + userAgent);
            return new ResponseEntity<>(HttpStatus.NO_CONTENT);
        }
    

    인가처리 시퀀스 다이어그램

    로그인 후 동작 가능한 마이페이지 조회 (/user) 를 예시로 그렸습니다. My-Books-인가 drawio (1)

    1. Front Server에서 Cookie Interceptor 를 이용해 쿠키 정보 확인

    2. RequiredAuthorization 어노테이션이 있는 경우 Authorization AOP 동작

    3. 토큰 정보를 헤더에 담아 gateway 로 요청

    4. Gateway Server 는 인증/인가가 필요한 경우와 그렇지 않은 경우를 나눠서 처리

        
          @Bean
        public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
            return builder.routes()
                    .route("auth", r -> r.path("/auth/**") // 전부 허용 할 것 , 토큰발급요청
                            .uri(urlProperties.getAuth()))
                    .route("api_user", p -> p.path("/api/member/**") // 유저 권한이 필요 한 경우
                            .filters(f -> f.filter(new AuthFilter(redisService).apply(new AuthFilter.Config())))
                            .uri(RESOURCE)
                    )
                    .route("api_admin", p -> p.path("/api/admin/**") // 어드민 권한이 필요 한 경우
                            .filters(f -> f.filter(new AuthFilter(redisService).apply(new AuthFilter.Config())))
                            .uri(RESOURCE)
                    )
                    .route("api_all", p -> p.path("/api/**") // 권한이 필요 없는 경우
                            .uri(RESOURCE)
                    )
                    .build();
            }
              
      
    5. Gateway Server 에서 토큰 검증 (토큰조작,만료,유저권한,유저상태)

    6. Gateway Server 검증 성공시 url 변경 및 Redis에서 유저 아이디를 찾아 헤더에 넣어 요청진행

             public static ServerHttpRequest getAdminRequest(ServerWebExchange exchange, String originalPath) {
      
             return exchange.getRequest().mutate()
                     .path(originalPath.replace("/api/admin/", "/api/")) // 새로운 URL 경로 설정
                     .build();
         }
      
         public static ServerHttpRequest getUserRequest(ServerWebExchange exchange, String originalPath, String key,
                                                     RedisService redisService) {
      
             return exchange.getRequest().mutate()
                     .path(originalPath.replace("/api/member/", "/api/")) // 새로운 URL 경로 설정
                     .header("X-User-Id", redisService.getValues(key)) // 유저 정보 보내기
                     .build();
         }
      
    7. Gateway Server 검증 실패시 에러 처리

      return ErrorResponseHandler.handleInvalidToken(exchange, HttpStatus.FORBIDDEN,
                      ErrorMessage.STATUS_IS_DORMANT_EXCEPTION.getMessage()); //  토큰은 유효한데 휴면 상태임
          } catch (StatusIsLockException e) {
              return ErrorResponseHandler.handleInvalidToken(exchange, HttpStatus.FORBIDDEN,
                      ErrorMessage.STATUS_IS_LOCK_EXCEPTION.getMessage()); //  토큰은 유효한데 잠금 상태임
          } catch (ForbiddenAccessException e) {
              return ErrorResponseHandler.handleInvalidToken(exchange, HttpStatus.FORBIDDEN,
                      ErrorMessage.INVALID_ACCESS.getMessage()); //  토큰은 유효한데 권한 없음 403
          } catch (TokenExpiredException e) {
              return ErrorResponseHandler.handleInvalidToken(exchange, HttpStatus.UNAUTHORIZED,
                      ErrorMessage.TOKEN_EXPIRED.getMessage()); // 토큰 만료됐음 인증 필요 401
          } catch (JWTVerificationException e) {
              return ErrorResponseHandler.handleInvalidToken(exchange, HttpStatus.UNAUTHORIZED,
                      ErrorMessage.INVALID_TOKEN.getMessage()); // 토큰이 조작됐음 올바르지 않은 요청 401
      
    8. Front Server Authorization AOP 에서 에러에 따른 응답을 선택

          @Around(value = "@annotation(store.mybooks.front.auth.Annotation.RequiredAuthorization)")
      public Object aroundMethod(ProceedingJoinPoint joinPoint) throws Throwable {
      
          HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(
                  RequestContextHolder.getRequestAttributes())).getRequest();
      
          HttpServletResponse response =
                  ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
      
          RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
          RequestContextHolder.currentRequestAttributes()
                  .setAttribute("authHeader", Utils.addAuthHeader(request), RequestAttributes.SCOPE_REQUEST);
      
          try {
              return joinPoint.proceed();
          } catch (RuntimeException e) {
      
              String error = e.getMessage();
              log.error("aop fin:" + error);
      
              if (error.contains(ErrorMessage.INVALID_ACCESS.getMessage())) { // 권한이 없음
                  throw new AccessIdForbiddenException(); // 인덱스로 보내기
              } else if (error.contains(ErrorMessage.TOKEN_EXPIRED.getMessage())) { // 토큰만료 재발급 받고 다시 부르기
      
                  // 토큰을 갱신하는 요청을 보냄 (기존 엑세스 토큰을 보냄)
                  RefreshTokenResponse refreshTokenResponse =
                          tokenAdaptor.refreshAccessToken(
                                  new RefreshTokenRequest((String) request.getAttribute("identity_cookie_value"),
                                          Utils.getUserIp(request), Utils.getUserAgent(request)));
      
                  // 리프래시 토큰 만료 됐거나 유효하지않음
                  if (Objects.isNull(refreshTokenResponse.getAccessToken())) {
                      throw new TokenExpiredException();
                  }
      
                  // 쿠키에 재발급한 엑세스토큰 넣어주고
                  CookieUtils.addJwtCookie(Objects.requireNonNull(response), refreshTokenResponse.getAccessToken());
                  // 헤더 설정해주고 기존 메서드 다시 불러
                  RequestContextHolder.currentRequestAttributes()
                          .setAttribute("authHeader", Utils.refreshAuthHeader(refreshTokenResponse.getAccessToken()),
                                  RequestAttributes.SCOPE_REQUEST);
                  // 어드민 쿠키를 체크하는 redis 만료시간 재설정
                  String adminCookieValue = (String) request.getAttribute("admin_cookie_value");
                  if (Objects.nonNull(adminCookieValue)) {
                      redisAuthService.expireValues(adminCookieValue, redisProperties.getAdminExpiration());
                      // 쿠키 만료시간 재설정
                      CookieUtils.addAdminCookie(response, adminCookieValue);
                  }
                  return joinPoint.proceed();
              } else if (error.contains(ErrorMessage.INVALID_TOKEN.getMessage())) { // 토큰위조됨 쿠키삭제
                  throw new AuthenticationIsNotValidException();
              } else if (error.contains(ErrorMessage.STATUS_IS_DORMANT_EXCEPTION.getMessage())) { // 휴면상태 -> 휴면인증사이트로
                  throw new StatusIsDormancyException();
              } else if (error.contains(ErrorMessage.STATUS_IS_LOCK_EXCEPTION.getMessage())) { // 잠금상태 -> 잠금인증 페이지로
                  throw new StatusIsLockException();
              }
      
              throw e; // 다른 에러인 경우 = 토큰관련 에러가 아닌경우 그대로 Exception 던진다
          }
      
      }
      
      
    9. Front Server Authorization AOP 에서 Exception 발생시 ControllerAdvice 가 잡아 분기처리

      // 토큰 인증/인가와 관련된 모든 예외를 잡음
      @ExceptionHandler({AuthenticationIsNotValidException.class, AccessIdForbiddenException.class,
              StatusIsDormancyException.class, TokenExpiredException.class, StatusIsLockException.class})
      public String handleAuthException(RuntimeException ex, HttpServletResponse response) {
      
          if (ex instanceof AuthenticationIsNotValidException | ex instanceof TokenExpiredException) {
              CookieUtils.deleteJwtCookie(Objects.requireNonNull(response)); // 쿠키 삭제
              CookieUtils.deleteAdminCookie(response);
              return "redirect:/login"; // 토큰조작 됐거나 , 만료됐음 -> 다시 로그인
          } else if (ex instanceof StatusIsDormancyException) {
              return "redirect:/verification/dormancy";  // 유저계정 휴면상태
          } else if (ex instanceof StatusIsLockException) {
              return "redirect:/verification/lock"; // 유정계정 잠금상태
          }
      
          // 권한없는 경우 index
          return "redirect:/";
      }
      
      

토큰

  • Access Token(30분) , Refresh Token(1시간)
    • 웹 환경에서의 서비스를 기본으로 하고있기 떄문에 공용 PC를 사용하는 경우가 발생 가능
      Refresh Token의 만료기간이 길 경우 다른 사용자가 사이트를 방문시 토큰이 갱신되며 로그인이 계속해서 유지됨
      만약 모바일 환경이라면 공용으로 핸드폰을 사용할 일은 없기 떄문에 1주일 이상의 긴 만료시간을 설정해도 괜찮다고 생각함

주소

  • 주소 등록 , 수정 , 삭제 , 조회
  • Daum 주소 api 를 이용해 우편번호 , 도로명 주소 조회
  • 최대 10개까지의 주소 저장

리뷰(상품평)

  • 리뷰 등록, 수정, 조회
  • 별점 부여 가능 (1 ~ 5)
  • 구매인만 리뷰 작성 가능
  • 리뷰는 구매한 도서당 1회만 작성 가능
  • 리뷰 작성시 포인트 적립
    • 이미지가 있는 리뷰와 없는 리뷰를 구분해 차등지급
  • 책 조회시 전체 리뷰 개수와 평점의 평균을 함께 보여줌

로그인 시연 login

마이페이지 시연 mypage

기능 시연

my-books 기능

Api Docs ApiDocs


About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published