-
My-Books는 고객이 책을 검색하고 주문할 수 있는 인터넷 서점입니다.
-
URL(만료): https://www.my-books.store
-
Api Docs(만료) : https://www.my-books.store/api-docs
박민수 |
신재훈 |
이담호 |
이승재 |
정재현 |
---|
- 개발도구: 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
- GitHub Projects의 RoadMap 사용
회원
- 회원가입 , 수정 , 탈퇴 ,조회
- 회원가입 시 유효성 검사 및 중복검사 , dooray message hook을 이용한 인증
- 회원 비밀번호는 BCrypt 를 사용해 암호화 하여 DB에 저장
- 로그아웃 , 탈퇴 , 비밀번호 변경 , 휴면인증 , 잠금인증
- Logout Interceptor 동작 , (auth + gateway) redis에 존재하는 리프래시토큰과 유저 아이디 정보 삭제 , Front 쿠키 삭제
- 회원 등급
- 등록 , 조회
- 회원 등급은 추가시 기존의 등급을 자동으로 대체 (기존 등급은 비활성으로 변경)
- 회원 상태
- 조회
- 활성 , 휴면 , 잠금 , 탈퇴 존재
- 90일간 로그인하지 않을 시 활성상태로 변경 , 계정 탈취시 잠금상태로 변경
- 활성상태는 Dooray Hook 을 이용한 인증을 이용해 활성상태로 변경 가능
- 잠금상태는 Dooray Hook 을 이용한 인증 및 비밀번호 변경으로 활성상태로 변경 가능
- 로그인 절차
- Front 에서 이메일과 비밀번호 를 입력
- 이메일 인증요청을 보냄
- 이메일 인증 성공시 BCrypt로 암호화된 비밀번호를 Front로 응답
- 사용자가 입력한 평문 비밀번호를 BCrypt로 암호화된 비밀번호와 검증
- 성공시 토큰발급 및 쿠키에 추가
- 로그인 포인트 적립,로그인 시간 갱신
- payco 로그인 절차
- 페이코 로그인 api 호출
- oauthId 로 최초 로그인 판별
- 최초 로그인시
- 사용자가 정보제공 동의를 한 경우
- 해당 정보로 회원가입 및 로그인 처리
- 비밀번호는 dummy 라는 값으로 등록 (로그인시 BCrypt로 검증하기 떄문에 dummy라는 평문으로는 로그인 인증 실패)
- 사용자가 정보제공 동의를 하지 않은 경우
- 이메일 , 생일 등의 정보를 입력받는 Form 으로 이동
- 해당 정보로 회원가입 및 로그인 처리
- 사용자가 정보제공 동의를 한 경우
- 최초 로그인이 아닐 시
- 로그인처리
인증/인가
-
로그인
-
로그인 성공시 Auth 서버 호출
-
Auth Server JWT 엑세스토큰을 발급 , 토큰 발급시 Redis에 (UUID+ip주소+X-User-Agent , 유저 아이디) 형식으로 저장 , 토큰에는 UUID가 기입
-
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); }
-
엑세스토큰을 응답으로 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); }
-
Front Server에서 Cookie Interceptor 를 이용해 쿠키 정보 확인
-
RequiredAuthorization 어노테이션이 있는 경우 Authorization AOP 동작
-
토큰 정보를 헤더에 담아 gateway 로 요청
-
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(); }
-
Gateway Server 에서 토큰 검증 (토큰조작,만료,유저권한,유저상태)
-
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(); }
-
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
-
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 던진다 } }
-
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주일 이상의 긴 만료시간을 설정해도 괜찮다고 생각함
- 웹 환경에서의 서비스를 기본으로 하고있기 떄문에 공용 PC를 사용하는 경우가 발생 가능
주소
- 주소 등록 , 수정 , 삭제 , 조회
- Daum 주소 api 를 이용해 우편번호 , 도로명 주소 조회
- 최대 10개까지의 주소 저장
리뷰(상품평)
- 리뷰 등록, 수정, 조회
- 별점 부여 가능 (1 ~ 5)
- 구매인만 리뷰 작성 가능
- 리뷰는 구매한 도서당 1회만 작성 가능
- 리뷰 작성시 포인트 적립
- 이미지가 있는 리뷰와 없는 리뷰를 구분해 차등지급
- 책 조회시 전체 리뷰 개수와 평점의 평균을 함께 보여줌
기능 시연