feat(customer vue, user-service): customer vue OAuth 인증

- customer vue 인증페이지 구현
- customer OAuth 로그인 구현
- user-service success handling 구현
This commit is contained in:
hoon7566
2022-03-03 20:29:19 +09:00
parent 2252a53e26
commit d40258a95b
23 changed files with 538 additions and 31 deletions

View File

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

View File

@@ -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<AuthorizationHeaderFilter.Config> {
@@ -44,9 +51,7 @@ public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<Auth
// JWT 토큰 판별
String token = authorizationHeader.replace("Bearer", "");
if (!jwtTokenProvider.validateJwtToken(token)) {
return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);
}
jwtTokenProvider.validateJwtToken(token);
String subject = jwtTokenProvider.getUserId(token);
if (false == jwtTokenProvider.getRoles(token).contains("Customer")) {

View File

@@ -0,0 +1,53 @@
package com.justpickup.customerapigatewayservice.filter;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Component
@Slf4j
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
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;
}
}

View File

@@ -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<Void> handle(ServerWebExchange exchange, Throwable ex) {
List<Class<? extends RuntimeException>> jwtExceptions =
List.of(SignatureException.class,
MalformedJwtException.class,
UnsupportedJwtException.class,
IllegalArgumentException.class);
Class<? extends Throwable> exceptionClass = ex.getClass();
Map<String, Object> 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));
}
}

View File

@@ -73,25 +73,12 @@ public class JwtTokenProvider {
return (List<String>) 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;
}
}

View File

@@ -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>.*),/$\{segment}
- id: user-service
uri: lb://USER-SERVICE
@@ -55,6 +75,13 @@ spring:
- Method=POST
filters:
- RewritePath=/user-service/(?<segment>.*),/$\{segment}
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/auth/reissue
- Method=GET
filters:
- RewritePath=/user-service/(?<segment>.*),/$\{segment}
- id: user-service
uri: lb://USER-SERVICE
predicates:

3
customer-vue/.env Normal file
View File

@@ -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

View File

@@ -19,6 +19,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"

View File

@@ -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");
}
}

View File

@@ -0,0 +1,34 @@
import axios from "axios";
export default {
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'
})
},
}

View File

@@ -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;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="53" height="52" viewBox="0 0 53 52">
<g fill="none" fill-rule="evenodd">
<g>
<g>
<g>
<g>
<g>
<g transform="translate(-794.000000, -2855.000000) translate(166.000000, 2282.000000) translate(0.500000, 381.500000) translate(486.500000, 191.500000) translate(73.500000, 0.000000) translate(68.000000, 0.000000)">
<circle cx="26" cy="26" r="26" fill="#1EC800"/>
<path fill="#FFF" fill-opacity="0" d="M12.9 13.265H38.9V39.265H12.9z"/>
<path fill="#FFF" d="M28.997 17.524L28.997 26.343 22.823 17.524 16.15 17.524 16.15 35.006 22.801 35.006 22.801 26.186 28.976 35.006 35.65 35.006 35.65 17.524z"/>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1000 B

View File

@@ -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;
}
}

View File

@@ -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);
}
);

View File

@@ -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: '/',
@@ -17,7 +25,26 @@ const routes = [
component: () => import('../views/HomeView')
}
]
}
},
{
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({

View File

@@ -0,0 +1,20 @@
<template>
<v-app>
<div>인증에 성공하였습니다.</div>
</v-app>
</template>
<script>
export default {
name: "AuthSuccess",
components: {
},mounted() {
opener.document.location.href=process.env.VUE_APP_BASEURL;
window.close();
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,120 @@
<template>
<v-container
fill-height
>
<v-row>
<v-col>
<div align="center" ><v-img
max-height="150"
max-width="250"
:src="logo"></v-img></div>
</v-col>
</v-row>
<v-row
justify="center"
>
<v-col class="align-content-center">
<v-form ref="form" lazy-validation>
<v-text-field
:rules="[v => /.+@.+\..+/.test(v) || 'E-mail must be valid', v => !!v || '이메일은 필수 값입니다']"
label="이메일"
prepend-icon="mdi-account-circle"
></v-text-field>
<v-text-field
:rules="[v => !!v || '비밀번호는 필수 값입니다']"
label="비밀번호"
type="Password"
prepend-icon="mdi-lock"
append-icon="mdi-eye-off"
></v-text-field>
<v-btn
block
>
Login
</v-btn>
<div class="d-block my-7" align="center">
<v-subheader class="d-inline" >소셜 아이디로 로그인해보세요!</v-subheader>
</div>
<div class="d-block " align="center">
<v-btn
class="mx-2"
fab
small
>
<v-img
class="d-inline-block align-lg-center mx-5"
style=""
max-width="38"
max-height="38"
:src="logo_naver"
@click="login_auth('naver')"
/>
</v-btn>
<v-btn
class="mx-2"
fab
small
>
<v-img
class="d-inline-block mx-5"
max-width="38"
max-height="38"
min-width="38"
min-height="38"
:src="logo_google"
@click="login_auth('google')"
/>
</v-btn>
</div>
</v-form>
</v-col>
</v-row>
</v-container>
</template>
<script>
import logo from '@/assets/justLogo.png'
import logo_naver from '@/assets/logo_naver.svg'
import logo_google from '@/assets/logo_google.png'
export default {
name: "LoginPage",
data (){
return {
logo : logo,
logo_naver: logo_naver,
logo_google : logo_google,
auth_popup: null,
}
},
watch: {
auth_popup : function () {
this.auth_popup.addEventListener('beforeunload', function() {
window.location.href=process.env.VUE_APP_BASEURL;
});
}
},
methods: {
login_auth: async function(target) {
const _url = process.env.VUE_APP_CUSTOMER_SERVICE_BASEURL+'/user-service/oauth2/authorization/'+target
this.auth_popup = window.open(
_url,
"",
"width=600,height=400,left=200,top=200"
);
},
}
}
</script>
<style scoped>
</style>

View File

@@ -1,3 +1,6 @@
module.exports = {
transpileDependencies: true
transpileDependencies: true,
devServer:{
allowedHosts: 'all',
}
}

View File

@@ -13,7 +13,6 @@ import reactor.core.publisher.Mono;
@Slf4j
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
private static final String TEST_CIRCUIT_BREAKER = "testCircuitBreaker";
public GlobalFilter(){
super(Config.class);

View File

@@ -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<OAuth2UserRequest, OAuth2
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
Long customerId = customer.getId();
return new DefaultOAuth2User(
authorities
, attributeDto.getAttributes()
, attributeDto.getNameAttributeKey());
}
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
String userEmail = String.valueOf(((DefaultOAuth2User) authentication.getPrincipal()).getAttributes().get("email"));
String refreshToken = jwtTokenProvider.createJwtRefreshToken();
Long customerId = customerRepository.findByEmail(userEmail).get().getId();
refreshTokenService.updateRefreshToken(customerId, jwtTokenProvider.getRefreshTokenId(refreshToken));
// 쿠키 설정
@@ -89,13 +100,19 @@ public class OAuthService implements OAuth2UserService<OAuth2UserRequest, OAuth2
response.setContentType(APPLICATION_JSON_VALUE);
response.addCookie(cookie);
return new DefaultOAuth2User(
authorities
, attributeDto.getAttributes()
, attributeDto.getNameAttributeKey());
// body 설정
String accessToken = jwtTokenProvider.createJwtAccessToken(String.valueOf(customerId), request.getRequestURI(), authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
Date expiredTime = jwtTokenProvider.getExpiredTime(accessToken);
response.sendRedirect("http://just-pickup.com:8080/auth?" +
"accessToken="+accessToken+
"&expiredTime="+new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(expiredTime));
}
@Transactional
public Customer saveCustomer(OAuthAttributeDto attributeDto){
return customerRepository.save(

View File

@@ -52,11 +52,11 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
.deleteCookies("refresh-token");
http.oauth2Login()
.defaultSuccessUrl("http://just-pickup.com:8080/")
.userInfoEndpoint()
.userService(oAuthService)
.and()
.failureUrl("http://just-pickup.com:8080/login");
.failureUrl("http://just-pickup.com:8080/login")
.successHandler(oAuthService::onAuthenticationSuccess);
http.addFilter(loginAuthenticationFilter);
// http.addFilterBefore(new HeaderAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);