diff --git a/server/.gitignore b/server/.gitignore index c41cc9e..ea78ef0 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1 +1,2 @@ -/target \ No newline at end of file +/target +/logs/app.log diff --git a/server/src/main/java/ru/tubryansk/tdms/config/SecurityConfiguration.java b/server/src/main/java/ru/tubryansk/tdms/config/SecurityConfiguration.java index c636afc..c09b22a 100644 --- a/server/src/main/java/ru/tubryansk/tdms/config/SecurityConfiguration.java +++ b/server/src/main/java/ru/tubryansk/tdms/config/SecurityConfiguration.java @@ -3,26 +3,22 @@ package ru.tubryansk.tdms.config; import jakarta.servlet.http.HttpSessionEvent; import jakarta.servlet.http.HttpSessionListener; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.ProviderManager; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; -import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.factory.PasswordEncoderFactories; @@ -34,10 +30,9 @@ import org.springframework.web.cors.CorsConfigurationSource; import java.time.Duration; import java.util.List; -import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY; - @Configuration +@Slf4j public class SecurityConfiguration { @Bean public SecurityFilterChain securityFilterChain( @@ -50,40 +45,49 @@ public class SecurityConfiguration { .csrf(AbstractHttpConfigurer::disable) .cors(a -> a.configurationSource(cors)) .authenticationManager(authenticationManager) - .sessionManagement(this::configureSessionManagement) + .sessionManagement(cfg -> { + cfg.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED); + cfg.maximumSessions(1); + }) .build(); } @Bean - @Profile("dev") @Qualifier("corsConfig") - public CorsConfigurationSource corsConfigurationDev() { + public CorsConfigurationSource corsConfigurationProd( + @Value("${application.domain}") String domain, + @Value("${application.port}") String port, + @Value("${application.protocol}") String protocol, + Environment environment + ) { return request -> { + String url = StringUtils.join(protocol, "://", domain, ":", port); CorsConfiguration corsConfiguration = new CorsConfiguration(); - corsConfiguration.applyPermitDefaultValues(); - corsConfiguration.addAllowedMethod("DELETE"); - corsConfiguration.addAllowedMethod("PUT"); - corsConfiguration.addAllowedMethod("PATCH"); + corsConfiguration.setMaxAge(Duration.ofDays(1)); + corsConfiguration.addAllowedOrigin(url); + if (environment.matchesProfiles("dev")) { + corsConfiguration.addAllowedOrigin("http://localhost:8081"); + } + + corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.OPTIONS.name())); + corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type")); + corsConfiguration.setAllowCredentials(true); return corsConfiguration; }; } @Bean - @Profile("!dev") - @Qualifier("corsConfig") - public CorsConfigurationSource corsConfigurationProd( - @Value("${application.domain}") String domain, - @Value("${application.port}") String port, - @Value("${application.protocol}") String protocol - ) { - return request -> { - String url = StringUtils.join(protocol, "://", domain, ":", port); - CorsConfiguration corsConfiguration = new CorsConfiguration(); - corsConfiguration.setMaxAge(Duration.ofHours(1)); - corsConfiguration.addAllowedOrigin(url); - corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name())); - // corsConfiguration.setAllowedHeaders(); - return corsConfiguration; + public HttpSessionListener httpSessionListener() { + return new HttpSessionListener() { + @Override + public void sessionCreated(HttpSessionEvent se) { + log.debug("Session created: {}, user {}", se.getSession().getId(), SecurityContextHolder.getContext().getAuthentication().getName()); + } + + @Override + public void sessionDestroyed(HttpSessionEvent se) { + log.debug("Session destroyed: {}, user: {}", se.getSession().getId(), SecurityContextHolder.getContext().getAuthentication().getName()); + } }; } @@ -97,29 +101,6 @@ public class SecurityConfiguration { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } - // @Bean - // @Profile("dev") - // public HttpSessionListener autoAuthenticateUnderAdmin(AuthenticationManager authenticationManager) { - // return new HttpSessionListener() { - // @Override - // public void sessionCreated(HttpSessionEvent se) { - // String username = "admin"; - // LoggerFactory.getLogger(this.getClass()).info("Session created {}. Authenticated, as {}", se.getSession().getId(), username); - // SecurityContext context = SecurityContextHolder.createEmptyContext(); - // UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, "1"); - // Authentication authenticated = authenticationManager.authenticate(authentication); - // context.setAuthentication(authenticated); - // SecurityContextHolder.setContext(context); - // se.getSession().setAttribute(SPRING_SECURITY_CONTEXT_KEY, context); - // } - // }; - // } - - // todo: remove when login/logout is implemented, since we do not need automatically created session with no authentication - private void configureSessionManagement(SessionManagementConfigurer sessionManagement) { - sessionManagement.sessionCreationPolicy(SessionCreationPolicy.ALWAYS); - } - private void configureHttpAuthorization(AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry httpAuthorization) { /* API ROUTES */ httpAuthorization.requestMatchers("/api/v1/user/logout").authenticated(); diff --git a/server/src/main/java/ru/tubryansk/tdms/controller/UserController.java b/server/src/main/java/ru/tubryansk/tdms/controller/UserController.java index 1e5bfc9..7d75f9a 100644 --- a/server/src/main/java/ru/tubryansk/tdms/controller/UserController.java +++ b/server/src/main/java/ru/tubryansk/tdms/controller/UserController.java @@ -3,10 +3,10 @@ package ru.tubryansk.tdms.controller; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import ru.tubryansk.tdms.dto.LoginDTO; import ru.tubryansk.tdms.dto.UserDTO; import ru.tubryansk.tdms.service.AuthenticationService; import ru.tubryansk.tdms.service.CallerService; -import ru.tubryansk.tdms.service.UserService; @RestController @RequestMapping("/api/v1/user") @@ -28,7 +28,7 @@ public class UserController { } @PostMapping("/login") - public void login(@RequestParam String username, @RequestParam String password) { - authenticationService.login(username, password); + public void login(@RequestBody LoginDTO loginDTO) { + authenticationService.login(loginDTO.username(), loginDTO.password()); } } diff --git a/server/src/main/java/ru/tubryansk/tdms/dto/LoginDTO.java b/server/src/main/java/ru/tubryansk/tdms/dto/LoginDTO.java new file mode 100644 index 0000000..a2810c3 --- /dev/null +++ b/server/src/main/java/ru/tubryansk/tdms/dto/LoginDTO.java @@ -0,0 +1,6 @@ +package ru.tubryansk.tdms.dto; + +public record LoginDTO( + String username, + String password) { +} \ No newline at end of file diff --git a/server/src/main/java/ru/tubryansk/tdms/entity/Role.java b/server/src/main/java/ru/tubryansk/tdms/entity/Role.java index 7cd51b1..d9ad154 100644 --- a/server/src/main/java/ru/tubryansk/tdms/entity/Role.java +++ b/server/src/main/java/ru/tubryansk/tdms/entity/Role.java @@ -15,7 +15,7 @@ import org.springframework.security.core.GrantedAuthority; @Setter @NoArgsConstructor @AllArgsConstructor -@Table(name = "role") +@Table(name = "`role`") public class Role implements GrantedAuthority { @Id @Column(name = "id") diff --git a/server/src/main/java/ru/tubryansk/tdms/entity/User.java b/server/src/main/java/ru/tubryansk/tdms/entity/User.java index 9236f14..e0f11b0 100644 --- a/server/src/main/java/ru/tubryansk/tdms/entity/User.java +++ b/server/src/main/java/ru/tubryansk/tdms/entity/User.java @@ -20,7 +20,7 @@ import java.util.List; @Setter @NoArgsConstructor @AllArgsConstructor -@Table(name = "user") +@Table(name = "`user`") public class User implements UserDetails { @Id @Column(name = "id") diff --git a/server/src/main/java/ru/tubryansk/tdms/service/AuthenticationService.java b/server/src/main/java/ru/tubryansk/tdms/service/AuthenticationService.java index 6ba7be3..5b12321 100644 --- a/server/src/main/java/ru/tubryansk/tdms/service/AuthenticationService.java +++ b/server/src/main/java/ru/tubryansk/tdms/service/AuthenticationService.java @@ -2,6 +2,7 @@ package ru.tubryansk.tdms.service; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -12,6 +13,7 @@ import ru.tubryansk.tdms.entity.User; import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY; @Service +@Slf4j public class AuthenticationService { @Autowired private HttpServletRequest request; @@ -31,10 +33,17 @@ public class AuthenticationService { } public void login(String username, String password) { - var context = SecurityContextHolder.createEmptyContext(); - var token = new UsernamePasswordAuthenticationToken(username, password); - var authenticated = authenticationManager.authenticate(token); - context.setAuthentication(authenticated); - request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context); + try { + var context = SecurityContextHolder.createEmptyContext(); + var token = new UsernamePasswordAuthenticationToken(username, password); + var authenticated = authenticationManager.authenticate(token); + context.setAuthentication(authenticated); + SecurityContextHolder.setContext(context); + request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context); + } catch (Exception e) { + log.error("Failed to log in user: {}. {}", username, e.getMessage()); + return; + } + log.debug("User {} logged in", username); } } diff --git a/server/src/main/java/ru/tubryansk/tdms/web/RequestLogger.java b/server/src/main/java/ru/tubryansk/tdms/web/RequestLogger.java new file mode 100644 index 0000000..24835b5 --- /dev/null +++ b/server/src/main/java/ru/tubryansk/tdms/web/RequestLogger.java @@ -0,0 +1,27 @@ +package ru.tubryansk.tdms.web; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.AbstractRequestLoggingFilter; + +@Component +@Slf4j +public class RequestLogger extends AbstractRequestLoggingFilter { + + @Override + protected void beforeRequest(HttpServletRequest request, String message) { + String logMessage = message + + ", method=" + request.getMethod() + + ", uri=" + request.getRequestURI() + + ", query=" + request.getQueryString() + + ", remote=" + request.getRemoteAddr() + + ", user=" + request.getRemoteUser(); + log.debug(logMessage); + } + + @Override + protected void afterRequest(HttpServletRequest request, String message) { + // do nothing + } +} diff --git a/web/src/components/page/layout/DefaultPage.tsx b/web/src/components/page/layout/DefaultPage.tsx index f1a0c9f..21f8ac7 100644 --- a/web/src/components/page/layout/DefaultPage.tsx +++ b/web/src/components/page/layout/DefaultPage.tsx @@ -18,7 +18,7 @@ class DefaultPage extends Component { static contextType = RootStoreContext; render() { - let isLoading = this.context.userStore.isLoading; + let isLoading = this.context.pendingStore.isThinking(); return <>
diff --git a/web/src/components/page/layout/Header.tsx b/web/src/components/page/layout/Header.tsx index 32a2101..f2560fd 100644 --- a/web/src/components/page/layout/Header.tsx +++ b/web/src/components/page/layout/Header.tsx @@ -2,60 +2,99 @@ import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap"; import {Component} from "react"; import {RouterLink} from "mobx-state-router"; import {IAuthenticated} from "../../../models/user"; -import {makeObservable} from "mobx"; import {RootStoreContext, RootStoreContextType} from "../../../context/RootStoreContext"; import {observer} from "mobx-react"; +import {post} from "../../../utils/request"; +import {LoginModal} from "../../user/LoginModal"; +import {ModalState} from "../../../state/ModalState"; +import {action, makeObservable} from "mobx"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; @observer class Header extends Component { declare context: RootStoreContextType; static contextType = RootStoreContext; + loginModalState = new ModalState(); + + constructor(props: any) { + super(props); + makeObservable(this); + } + + get loginThink() { + return this.context.pendingStore.isThinking('updateCurrentUser'); + } + + render() { + const userStore = this.context.userStore; + const user = userStore.user; + + return <> +
+ + + + TDMS + + + + + + +
+ + + } +} + +class AuthenticatedItems extends Component { + declare context: RootStoreContextType; + static contextType = RootStoreContext; + constructor(props: any) { super(props); makeObservable(this); } + @action.bound + logout() { + post('user/logout').then(() => this.context.userStore.updateCurrentUser()); + } + render() { const userStore = this.context.userStore; - const routerStore = this.context.routerStore; const user = userStore.user; - return
- - - - TDMS - - - - - - -
+ return <> + Пользователь: + + Моя страница + + Выйти + + } } diff --git a/web/src/components/user/LoginModal.tsx b/web/src/components/user/LoginModal.tsx new file mode 100644 index 0000000..54ed381 --- /dev/null +++ b/web/src/components/user/LoginModal.tsx @@ -0,0 +1,121 @@ +import {ChangeEvent, Component} from "react"; +import {Button, FormControl, FormGroup, FormLabel, FormText, Modal} from "react-bootstrap"; +import {ModalState} from "../../state/ModalState"; +import {observer} from "mobx-react"; +import {action, computed, makeObservable, observable, reaction} from "mobx"; +import {post} from "../../utils/request"; +import {RootStoreContext, RootStoreContextType} from "../../context/RootStoreContext"; + +interface LoginModalProps { + modalState: ModalState; +} + +@observer +export class LoginModal extends Component { + declare context: RootStoreContextType; + static contextType = RootStoreContext; + modalState = this.props.modalState; + @observable login = ''; + @observable loginError = ''; + @observable password = ''; + @observable passwordError = ''; + @observable rememberMe = false; + + constructor(props: LoginModalProps) { + super(props); + makeObservable(this); + reaction(() => this.login, this.validateLogin); + reaction(() => this.password, this.validatePassword); + } + + @action.bound + validateLogin() { + if (!this.login) { + this.loginError = 'Имя пользователя не может быть пустым'; + } else if (this.login.length < 5) { + this.loginError = 'Имя пользователя должно быть не менее 5 символов'; + } else if (this.login.length > 50) { + this.loginError = 'Имя пользователя должно быть не более 50 символов'; + } else if (!/^[a-zA-Z0-9_]+$/.test(this.login)) { + this.loginError = 'Имя пользователя должно содержать только латинские буквы, цифры и знак подчеркивания'; + } else { + this.loginError = ''; + } + } + + @action.bound + validatePassword() { + if (!this.password) { + this.passwordError = 'Пароль не может быть пустым'; + } else if (this.password.length < 5) { + this.passwordError = 'Пароль должен быть не менее 5 символов'; + } else if (this.password.length > 32) { + this.passwordError = 'Пароль должен быть не более 32 символов'; + } else if (!/^[a-zA-Z0-9!@#$%^&*()_+]+$/.test(this.password)) { + this.passwordError = 'Пароль должен содержать только латинские буквы, цифры и специальные символы'; + } else { + this.passwordError = ''; + } + } + + @computed + get loginButtonDisabled() { + return !this.login || !this.password || !!this.loginError || !!this.passwordError; + } + + @action.bound + onLoginInput(event: ChangeEvent) { + this.login = event.target.value; + } + + @action.bound + onPasswordInput(event: ChangeEvent) { + this.password = event.target.value; + } + + @action.bound + onLogin() { + if (this.loginButtonDisabled) + return; + + post('user/login', { + username: this.login, + password: this.password + }).then(() => { + this.context.userStore.updateCurrentUser(); + this.modalState.close(); + }); + } + + render() { + return + + Вход + + + + Имя пользователя + + { + this.loginError && + {this.loginError} + } + + + Пароль + + { + this.passwordError && + {this.passwordError} + } + + + + + + + + + + } +} \ No newline at end of file diff --git a/web/src/components/page/UserProfile.tsx b/web/src/components/user/UserProfilePage.tsx similarity index 98% rename from web/src/components/page/UserProfile.tsx rename to web/src/components/user/UserProfilePage.tsx index dc94dae..a080735 100644 --- a/web/src/components/page/UserProfile.tsx +++ b/web/src/components/user/UserProfilePage.tsx @@ -1,4 +1,4 @@ -import {DefaultPage} from "./layout/DefaultPage"; +import {DefaultPage} from "../page/layout/DefaultPage"; import {Col, Form, Row} from "react-bootstrap"; import {observer} from "mobx-react"; import {RootStoreContext, type RootStoreContextType} from "../../context/RootStoreContext"; @@ -151,7 +151,7 @@ class StudentInfo extends Component<{student: IStudent}> { } } -export default class UserProfile extends DefaultPage { +export default class UserProfilePage extends DefaultPage { declare context: RootStoreContextType; static contextType = RootStoreContext; diff --git a/web/src/models/role.ts b/web/src/models/role.ts index 3c73756..aede1f3 100644 --- a/web/src/models/role.ts +++ b/web/src/models/role.ts @@ -1,3 +1,4 @@ +// todo: update export enum Role { STUDENT = 'ROLE_STUDENT', TUTOR = 'ROLE_TUTOR', diff --git a/web/src/router/viewMap.tsx b/web/src/router/viewMap.tsx index 120efd9..1d024ec 100644 --- a/web/src/router/viewMap.tsx +++ b/web/src/router/viewMap.tsx @@ -1,10 +1,10 @@ import {ViewMap} from "mobx-state-router"; import Home from "../components/page/Home"; import Error from "../components/page/Error"; -import UserProfile from "../components/page/UserProfile"; +import UserProfilePage from "../components/user/UserProfilePage"; export const viewMap: ViewMap = { root: , - profile: , + profile: , error: , } \ No newline at end of file diff --git a/web/src/state/ModalState.ts b/web/src/state/ModalState.ts new file mode 100644 index 0000000..15c959e --- /dev/null +++ b/web/src/state/ModalState.ts @@ -0,0 +1,20 @@ +import {action, makeObservable, observable} from "mobx"; + +export class ModalState { + @observable isOpen: boolean; + + constructor(isOpen: boolean = false) { + makeObservable(this); + this.isOpen = isOpen; + } + + @action.bound + open = () => { + this.isOpen = true; + }; + + @action.bound + close = () => { + this.isOpen = false; + }; +} \ No newline at end of file diff --git a/web/src/store/PendingStore.ts b/web/src/store/PendingStore.ts new file mode 100644 index 0000000..828a916 --- /dev/null +++ b/web/src/store/PendingStore.ts @@ -0,0 +1,41 @@ +import {action, makeObservable, observable} from "mobx"; +import {RootStore} from "./RootStore"; + +export class PendingStore { + rootStore: RootStore; + @observable pendingMap = new Map(); + + constructor(rootStore: RootStore) { + this.rootStore = rootStore; + makeObservable(this); + } + + @action.bound + think(key: string = '$default') { + const count = this.pendingMap.get(key) || 0; + this.pendingMap.set(key, count + 1); + } + + @action.bound + completeOne(key: string) { + const count = this.pendingMap.get(key); + if (count === undefined) { + return; + } + + this.pendingMap.set(key, count - 1); + if (this.pendingMap.get(key) === 0) { + this.pendingMap.delete(key); + } + } + + @action.bound + completeAll(key: string) { + this.pendingMap.delete(key); + } + + @action.bound + isThinking(key: string = '$default') { + return this.pendingMap.get(key) !== undefined; + } +} \ No newline at end of file diff --git a/web/src/store/RootStore.ts b/web/src/store/RootStore.ts index d66b9a9..0f79275 100644 --- a/web/src/store/RootStore.ts +++ b/web/src/store/RootStore.ts @@ -1,7 +1,9 @@ import {MyRouterStore} from "./MyRouterStore"; import {UserStore} from "./UserStore"; +import {PendingStore} from "./PendingStore"; export class RootStore { + pendingStore = new PendingStore(this); userStore = new UserStore(this); routerStore = new MyRouterStore(this); diff --git a/web/src/store/UserStore.ts b/web/src/store/UserStore.ts index c7c7a68..ca5b254 100644 --- a/web/src/store/UserStore.ts +++ b/web/src/store/UserStore.ts @@ -1,5 +1,5 @@ import {get} from "../utils/request"; -import {makeObservable, observable, runInAction} from "mobx"; +import {action, makeObservable, observable, runInAction} from "mobx"; import {RootStore} from "./RootStore"; import type {IUser} from "../models/user"; import {IStudent} from "../models/student"; @@ -7,25 +7,23 @@ import {Role} from "../models/role"; export class UserStore { rootStore: RootStore; - @observable - user: IUser = {authenticated: false}; - @observable - student: IStudent | undefined; - @observable - isLoading: boolean = true; + @observable user: IUser = {authenticated: false}; + @observable student?: IStudent; constructor(rootStore: RootStore) { makeObservable(this); this.rootStore = rootStore; } - fetchCurrentUserData() { + @action.bound + updateCurrentUser() { // todo: store token in localStorage + this.rootStore.pendingStore.think('updateCurrentUser'); get('/user/current').then((response) => { runInAction(() => { this.user = response; }); - if(response.authenticated && response.authorities.some(a => a.authority === Role.STUDENT)) { + if (response.authenticated && response.authorities.some(a => a.authority === Role.STUDENT)) { get('/student/current').then((student) => { runInAction(() => { this.student = student; @@ -34,13 +32,13 @@ export class UserStore { } }).finally(() => { runInAction(() => { - this.isLoading = false; + this.rootStore.pendingStore.completeOne('updateCurrentUser'); }); }); } init() { - this.fetchCurrentUserData(); + this.updateCurrentUser(); return this; } } \ No newline at end of file diff --git a/web/src/utils/request.tsx b/web/src/utils/request.tsx index 2f5201c..4fb738f 100644 --- a/web/src/utils/request.tsx +++ b/web/src/utils/request.tsx @@ -21,7 +21,7 @@ export const post = async (url: string, data?: any) => { export const request = async (config: AxiosRequestConfig) => { return new Promise((resolve, reject) => { console.debug(`${config.method} ${config.url} request: ${config.method === 'GET' ? JSON.stringify(config.params) : JSON.stringify(config.data)}`); - axios.request({...config, baseURL: apiUrl}).then((response) => { + axios.request({...config, baseURL: apiUrl, withCredentials: true}).then((response) => { console.debug(`${config.method} ${config.url} response: ${JSON.stringify(response.data)}`); resolve(response.data); }).catch((error) => {