diff --git a/customer-apigateway-service/build.gradle b/customer-apigateway-service/build.gradle index 5094502..b8cc022 100644 --- a/customer-apigateway-service/build.gradle +++ b/customer-apigateway-service/build.gradle @@ -31,6 +31,8 @@ dependencies { // https://mvnrepository.com/artifact/io.netty/netty-resolver-dns-native-macos implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' annotationProcessor 'org.projectlombok:lombok' diff --git a/customer-apigateway-service/src/main/java/com/justpickup/customerapigatewayservice/filter/AuthorizationHeaderFilter.java b/customer-apigateway-service/src/main/java/com/justpickup/customerapigatewayservice/filter/AuthorizationHeaderFilter.java index a8af87b..017c185 100644 --- a/customer-apigateway-service/src/main/java/com/justpickup/customerapigatewayservice/filter/AuthorizationHeaderFilter.java +++ b/customer-apigateway-service/src/main/java/com/justpickup/customerapigatewayservice/filter/AuthorizationHeaderFilter.java @@ -1,18 +1,25 @@ package com.justpickup.customerapigatewayservice.filter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.justpickup.customerapigatewayservice.security.JwtTokenProvider; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.HashMap; +import java.util.Map; + @Component @Slf4j public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory { @@ -44,9 +51,7 @@ public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory { + + public GlobalFilter(){ + super(Config.class); + } + + @Override + public GatewayFilter apply(Config config) { + return (exchange, chain) -> { + ServerHttpRequest request = exchange.getRequest(); // reactive포함된거로 import + ServerHttpResponse response = exchange.getResponse(); + + log.info("Global com.example.scg.filter baseMessgae: {}", config.getBaseMessage()); + + // Global pre Filter + if (config.isPreLogger()){ + log.info("Global Filter Start: request id -> {}" , request.getId()); + log.info("Global Filter Start: request path -> {}" , request.getPath()); + } + + // Global Post Filter + //Mono는 webflux에서 단일값 전송할때 Mono값으로 전송 + return chain.filter(exchange).then(Mono.fromRunnable(()->{ + + if (config.isPostLogger()){ + log.info("Global Filter End: response statuscode -> {}" , response.getStatusCode()); + } + })); + + }; + } + + @Data + public static class Config { + private String baseMessage; + private boolean preLogger; + private boolean postLogger; + } +} diff --git a/customer-apigateway-service/src/main/java/com/justpickup/customerapigatewayservice/handler/GlobalExceptionHandler.java b/customer-apigateway-service/src/main/java/com/justpickup/customerapigatewayservice/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..b40813e --- /dev/null +++ b/customer-apigateway-service/src/main/java/com/justpickup/customerapigatewayservice/handler/GlobalExceptionHandler.java @@ -0,0 +1,64 @@ +package com.justpickup.customerapigatewayservice.handler; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureException; +import io.jsonwebtoken.UnsupportedJwtException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class GlobalExceptionHandler implements ErrorWebExceptionHandler { + + @Autowired + private ObjectMapper objectMapper; + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + List> jwtExceptions = + List.of(SignatureException.class, + MalformedJwtException.class, + UnsupportedJwtException.class, + IllegalArgumentException.class); + Class exceptionClass = ex.getClass(); + + Map responseBody = new HashMap<>(); + if (exceptionClass == ExpiredJwtException.class) { + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + responseBody.put("code", "EXPIRED"); + responseBody.put("message", "Access Token is Expired!"); + } else if (jwtExceptions.contains(exceptionClass)){ + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + responseBody.put("code", "INVALID"); + responseBody.put("message", "Invalid Access Token"); + }else{ + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + responseBody.put("code", "INVALID"); + } + + DataBuffer wrap = null; + try { + byte[] bytes = objectMapper.writeValueAsBytes(responseBody); + wrap = exchange.getResponse().bufferFactory().wrap(bytes); + } catch (JsonProcessingException e) { + e.printStackTrace(); + } + + return exchange.getResponse().writeWith(Flux.just(wrap)); + } + +} diff --git a/customer-apigateway-service/src/main/java/com/justpickup/customerapigatewayservice/security/JwtTokenProvider.java b/customer-apigateway-service/src/main/java/com/justpickup/customerapigatewayservice/security/JwtTokenProvider.java index af5fc77..99404d9 100644 --- a/customer-apigateway-service/src/main/java/com/justpickup/customerapigatewayservice/security/JwtTokenProvider.java +++ b/customer-apigateway-service/src/main/java/com/justpickup/customerapigatewayservice/security/JwtTokenProvider.java @@ -73,25 +73,12 @@ public class JwtTokenProvider { return (List) getClaimsFromJwtToken(token).get("roles"); } - public boolean validateJwtToken(String token) { + public void validateJwtToken(String token) { try { Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token); - return true; - } catch (SignatureException e) { - log.error("Invalid JWT signature: {}", e.getMessage()); - return false; - } catch (MalformedJwtException e) { - log.error("Invalid JWT token: {}", e.getMessage()); - return false; - } catch (ExpiredJwtException e) { - log.error("JWT token is expired: {}", e.getMessage()); - return false; - } catch (UnsupportedJwtException e) { - log.error("JWT token is unsupported: {}", e.getMessage()); - return false; - } catch (IllegalArgumentException e) { - log.error("JWT claims string is empty: {}", e.getMessage()); - return false; + } catch (SignatureException | MalformedJwtException | + UnsupportedJwtException | IllegalArgumentException | ExpiredJwtException jwtException) { + throw jwtException; } } diff --git a/customer-apigateway-service/src/main/resources/application.yml b/customer-apigateway-service/src/main/resources/application.yml index 31f9c15..f42e06c 100644 --- a/customer-apigateway-service/src/main/resources/application.yml +++ b/customer-apigateway-service/src/main/resources/application.yml @@ -15,6 +15,25 @@ spring: cloud: gateway: + default-filters: + - name: GlobalFilter + args: + baseMessage: Spring Cloud Gateway Global Filter + preLogger: true + postLogger: true + globalcors: + cors-configurations: + '[/**]': + allowedOrigins: "http://just-pickup.com:8080" + allowedMethods: + - GET + - POST + - DELETE + - PUT + - OPTIONS + - DELETE + allowedHeaders: '*' + allow-credentials: true routes: - id: owner-frontend-service uri: lb://CUSTOMER-FRONTEND-SERVICE @@ -33,6 +52,7 @@ spring: predicates: - Path=/store-service/** filters: + - AuthorizationHeaderFilter - RewritePath=/store-service/(?.*),/$\{segment} - id: user-service uri: lb://USER-SERVICE @@ -55,6 +75,13 @@ spring: - Method=POST filters: - RewritePath=/user-service/(?.*),/$\{segment} + - id: user-service + uri: lb://USER-SERVICE + predicates: + - Path=/user-service/auth/reissue + - Method=GET + filters: + - RewritePath=/user-service/(?.*),/$\{segment} - id: user-service uri: lb://USER-SERVICE predicates: diff --git a/customer-vue/.env b/customer-vue/.env new file mode 100644 index 0000000..999a455 --- /dev/null +++ b/customer-vue/.env @@ -0,0 +1,3 @@ +VUE_APP_BASEURL=http://just-pickup.com:8080 +VUE_APP_OWNER_SERVICE_BASEURL=http://just-pickup.com:8001 +VUE_APP_CUSTOMER_SERVICE_BASEURL=http://just-pickup.com:8000 \ No newline at end of file diff --git a/customer-vue/package.json b/customer-vue/package.json index 0977d00..a1fca58 100644 --- a/customer-vue/package.json +++ b/customer-vue/package.json @@ -20,6 +20,8 @@ "@vue/cli-plugin-babel": "^5.0.0", "@vue/cli-plugin-eslint": "^5.0.0", "@vue/cli-service": "^5.0.0", + "axios": "^0.26.0", + "moment": "^2.29.1", "eslint": "^7.32.0", "eslint-plugin-vue": "^8.0.3", "vue-template-compiler": "^2.6.14" diff --git a/customer-vue/src/api/auth.js b/customer-vue/src/api/auth.js new file mode 100644 index 0000000..2c51de7 --- /dev/null +++ b/customer-vue/src/api/auth.js @@ -0,0 +1,26 @@ +import axios from "axios"; +import jwt from "@/common/jwt"; + +export default { + async requestReissue() { + const config = { + headers: { + "X-AUTH-TOKEN": jwt.getToken() + } + } + + const res = await axios.get(process.env.VUE_APP_CUSTOMER_SERVICE_BASEURL+"/user-service/auth/reissue", config); + + const accessToken = res.data.data.accessToken; + jwt.saveToken(accessToken); + jwt.saveExpiredTime(res.data.data.expiredTime); + axios.defaults.headers.common['Authorization'] = "Bearer " + accessToken; + + return accessToken; + }, + requestCheckAccessToken() { + axios.defaults.headers.common['Authorization'] = "Bearer " + jwt.getToken(); + + return axios.get(process.env.VUE_APP_CUSTOMER_SERVICE_BASEURL+"/user-service/auth/check/access-token"); + } +} \ No newline at end of file diff --git a/customer-vue/src/api/store.js b/customer-vue/src/api/store.js index 074fd51..6c685a2 100644 --- a/customer-vue/src/api/store.js +++ b/customer-vue/src/api/store.js @@ -1,15 +1,45 @@ import axios from "axios"; export default { - requestSearchStore(latitude, longitude, storeName, page) { - const options = { - params: { - latitude: latitude, - longitude: longitude, - storeName: storeName, - page: page + requestSearchStore(latitude, longitude, storeName, page) { + const options = { + params: { + latitude: latitude, + longitude: longitude, + storeName: storeName, + page: page + } } - } - return axios.get("http://localhost:8000/store-service/search-store", options); - } + return axios.get("http://localhost:8000/store-service/search-store", options); + }, + getCategoryList(){ + return axios.get(process.env.VUE_APP_CUSTOMER_SERVICE_BASEURL+'/store-service/category/'); + }, + putCategoryList(data){ + return axios({ + method:'put', + url:process.env.VUE_APP_CUSTOMER_SERVICE_BASEURL+'/store-service/category', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json;charset=UTF-8' + }, + data: data, + responseType:'json' + }) + }, + getItemById(itemId){ + return axios.get(process.env.VUE_APP_CUSTOMER_SERVICE_BASEURL+'/store-service/item/'+itemId) + }, + saveItem(method, itemData){ + return axios({ + method:method, + url: process.env.VUE_APP_CUSTOMER_SERVICE_BASEURL+'/store-service/item', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json;charset=UTF-8' + }, + data: itemData, + responseType:'json' + }) + }, } \ No newline at end of file diff --git a/customer-vue/src/api/user.js b/customer-vue/src/api/user.js new file mode 100644 index 0000000..7eb0b07 --- /dev/null +++ b/customer-vue/src/api/user.js @@ -0,0 +1,32 @@ +import axios from "axios"; +import jwt from '../common/jwt.js'; + +export default { + requestRegisterUser(user) { + return axios.post(process.env.VUE_APP_CUSTOMER_SERVICE_BASEURL+"/user-service/store-owner", user); + }, + + async requestLoginUser(email, password) { + const user = { + email: email, + password: password + } + + try { + const response = await axios.post(process.env.VUE_APP_CUSTOMER_SERVICE_BASEURL+"/user-service/login", user); + const data = response.data.data; + + jwt.saveToken(data.accessToken); + jwt.saveExpiredTime(data.expiredTime); + + axios.defaults.headers.common['Authorization'] = "Bearer " + data.accessToken; + + return true; + } catch (err) { + console.log("Error = ", err); + return false; + } + + } +} + diff --git a/customer-vue/src/assets/justLogo.png b/customer-vue/src/assets/justLogo.png new file mode 100644 index 0000000..538e028 Binary files /dev/null and b/customer-vue/src/assets/justLogo.png differ diff --git a/customer-vue/src/assets/logo_google.png b/customer-vue/src/assets/logo_google.png new file mode 100644 index 0000000..919dd47 Binary files /dev/null and b/customer-vue/src/assets/logo_google.png differ diff --git a/customer-vue/src/assets/logo_naver.svg b/customer-vue/src/assets/logo_naver.svg new file mode 100644 index 0000000..ca32db0 --- /dev/null +++ b/customer-vue/src/assets/logo_naver.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/customer-vue/src/common/jwt.js b/customer-vue/src/common/jwt.js new file mode 100644 index 0000000..0783eb5 --- /dev/null +++ b/customer-vue/src/common/jwt.js @@ -0,0 +1,38 @@ + +const moment = require('moment'); +const ACCESS_TOKEN_NAME = "accessToken"; +const EXPIRED_TIME_NAME = "expiredTime"; + +const tag = "[jwt]"; + +export default { + getToken() { + return localStorage.getItem(ACCESS_TOKEN_NAME); + }, + saveToken(token) { + localStorage.setItem(ACCESS_TOKEN_NAME, token); + }, + getExpiredTime() { + return localStorage.getItem(EXPIRED_TIME_NAME); + }, + saveExpiredTime(expiredTime) { + localStorage.setItem(EXPIRED_TIME_NAME, expiredTime); + }, + destroyAll() { + localStorage.removeItem(ACCESS_TOKEN_NAME); + localStorage.removeItem(EXPIRED_TIME_NAME); + }, + isExpired() { + const expiredTime = this.getExpiredTime(); + + const expiredMoment = moment(expiredTime); + let currentMoment = moment(); + + const difference = moment.duration(expiredMoment.diff(currentMoment)).asSeconds(); + + console.log(tag, "expireMoment = ", expiredMoment, "currentMoment = ", currentMoment, "diff = ", difference); + + // 만료 30초 전일 경우 만료로 판단 + return difference <= 30; + } +} \ No newline at end of file diff --git a/customer-vue/src/main.js b/customer-vue/src/main.js index 5e20f20..06d671f 100644 --- a/customer-vue/src/main.js +++ b/customer-vue/src/main.js @@ -2,7 +2,10 @@ import Vue from 'vue'; import App from './App.vue'; import vuetify from "@/plugins/vuetify"; import router from "./router/router.js"; +import axios from "axios"; +import auth from "@/api/auth"; +axios.defaults.withCredentials = true; Vue.config.productionTip = false new Vue({ @@ -10,3 +13,29 @@ new Vue({ router, render: h => h(App), }).$mount('#app') + +axios.interceptors.response.use( + (response) => { + return response; + }, + async (error) => { + const originalRequest = error.config; + if (error.response.status === 401) { + let code = error.response.data.code; + if (code === "EXPIRED") { + console.log("## expired"); + try { + const accessToken = await auth.requestReissue(); + originalRequest.headers.Authorization = "Bearer " + accessToken; + return axios(originalRequest); + } catch (reissueError) { + window.location.href = process.env.VUE_APP_BASEURL+"/login"; + alert("권한이 없습니다. 다시 로그인 해주세요"); + } + } + window.location.href = process.env.VUE_APP_BASEURL+"/login"; + alert("권한이 없습니다. 다시 로그인해주세요."); + } + return Promise.reject(error); + } +); \ No newline at end of file diff --git a/customer-vue/src/router/router.js b/customer-vue/src/router/router.js index c029e50..ffc3523 100644 --- a/customer-vue/src/router/router.js +++ b/customer-vue/src/router/router.js @@ -2,9 +2,17 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; import HomeLayout from '../views/Layout/HomeLayout.vue'; +const ACCESS_TOKEN_NAME = "accessToken"; +const EXPIRED_TIME_NAME = "expiredTime"; Vue.use(VueRouter); +const auth = async function (to, from, next) { + localStorage.setItem(ACCESS_TOKEN_NAME, to.query.accessToken); + localStorage.setItem(EXPIRED_TIME_NAME, to.query.expiredTime) + next(); +}; + const routes = [ { path: '/', @@ -22,7 +30,26 @@ const routes = [ component: () => import('../views/SearchStore') } ] - } + }, + { + path: '/login', + redirect: 'login', + component: HomeLayout, + children: [ + { + path: "/login", + name: 'login', + component: () => import('../views/LoginPage') + } + ] + }, + { + path: '/auth', + name: 'auth', + beforeEnter: auth, + component: () => import('../views/Layout/AuthSuccess.vue') + + }, ] const router = new VueRouter({ diff --git a/customer-vue/src/views/Layout/AuthSuccess.vue b/customer-vue/src/views/Layout/AuthSuccess.vue new file mode 100644 index 0000000..579fe10 --- /dev/null +++ b/customer-vue/src/views/Layout/AuthSuccess.vue @@ -0,0 +1,20 @@ + + + + + \ No newline at end of file diff --git a/customer-vue/src/views/LoginPage.vue b/customer-vue/src/views/LoginPage.vue new file mode 100644 index 0000000..48e640d --- /dev/null +++ b/customer-vue/src/views/LoginPage.vue @@ -0,0 +1,120 @@ + + + + + \ No newline at end of file diff --git a/customer-vue/vue.config.js b/customer-vue/vue.config.js index 5b768af..99fd690 100644 --- a/customer-vue/vue.config.js +++ b/customer-vue/vue.config.js @@ -1,3 +1,6 @@ module.exports = { - transpileDependencies: true + transpileDependencies: true, + devServer:{ + allowedHosts: 'all', + } } diff --git a/owner-apigateway-service/src/main/java/com/justpickup/ownerapigatewayservice/filter/GlobalFilter.java b/owner-apigateway-service/src/main/java/com/justpickup/ownerapigatewayservice/filter/GlobalFilter.java index a34d533..e052f7a 100644 --- a/owner-apigateway-service/src/main/java/com/justpickup/ownerapigatewayservice/filter/GlobalFilter.java +++ b/owner-apigateway-service/src/main/java/com/justpickup/ownerapigatewayservice/filter/GlobalFilter.java @@ -13,7 +13,6 @@ import reactor.core.publisher.Mono; @Slf4j public class GlobalFilter extends AbstractGatewayFilterFactory { - private static final String TEST_CIRCUIT_BREAKER = "testCircuitBreaker"; public GlobalFilter(){ super(Config.class); diff --git a/user-service/src/main/java/com/justpickup/userservice/domain/jwt/service/OAuthService.java b/user-service/src/main/java/com/justpickup/userservice/domain/jwt/service/OAuthService.java index 8fad368..ce8ffde 100644 --- a/user-service/src/main/java/com/justpickup/userservice/domain/jwt/service/OAuthService.java +++ b/user-service/src/main/java/com/justpickup/userservice/domain/jwt/service/OAuthService.java @@ -22,6 +22,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import javax.servlet.ServletException; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -75,10 +76,20 @@ public class OAuthService implements OAuth2UserService