diff --git a/.gitignore b/.gitignore index ec6b5f9..99a8375 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,5 @@ bin/ .DS_Store ### .env files ### -.env \ No newline at end of file +.env +*.p12 \ No newline at end of file diff --git a/APIGateway/.env.example b/APIGateway/.env.example index 932b516..b45127a 100644 --- a/APIGateway/.env.example +++ b/APIGateway/.env.example @@ -1,9 +1,17 @@ AUTHENTIK_CLIENT_ID= authentik oauth2 client id AUTHENTIK_CLIENT_SECRET= authentik oauth2 client secret +AUTHENTIK_ISSUER_URL= authentik issuer url ( dev: https://auth.mvvasilev.dev/application/o/personal-finances/ ) +AUTHENTIK_BACK_CHANNEL_LOGOUT_URL= authentik back channel logout url ( dev: https://auth.mvvasilev.dev/application/o/personal-finances/end-session/ ) GATEWAY_URI= http://localhost:8080 API_URI= http://localhost:8081 FRONTEND_URI= http://localhost:5173 +SSL_ENABLED= true if generated an ssl cert ( keytool -genkeypair -alias local -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore local.p12 -validity 3650 ) +SSL_KEY_STORE_TYPE= type of key store ( PKCS12 ) +SSL_KEY_STORE= where to find the key store ( classpath:keystore/local.p12 if located in main/resources/keystore/local.p12 ) +SSL_KEY_STORE_PASSWORD= the password for the key store +SSL_KEY_ALIAS= the key store alias + REDIS_HOST= the address of the redis host REDIS_PORT= the port of redis ( 6379 by default ) \ No newline at end of file diff --git a/APIGateway/src/main/java/dev/mvvasilev/gateway/SecurityConfiguration.java b/APIGateway/src/main/java/dev/mvvasilev/gateway/SecurityConfiguration.java index d35d9fb..eca28de 100644 --- a/APIGateway/src/main/java/dev/mvvasilev/gateway/SecurityConfiguration.java +++ b/APIGateway/src/main/java/dev/mvvasilev/gateway/SecurityConfiguration.java @@ -8,6 +8,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; @@ -19,17 +20,28 @@ import org.springframework.security.oauth2.client.web.server.ServerOAuth2Authori import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.ServerAuthenticationEntryPoint; import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisWebSession; import org.springframework.web.server.WebSession; +import java.time.Duration; + @Configuration @EnableWebFluxSecurity @EnableRedisWebSession public class SecurityConfiguration implements BeanClassLoaderAware { + public static final String IS_LOGGED_IN_COOKIE = "isLoggedIn"; + @Value("${spring.security.oauth2.client.provider.authentik.back-channel-logout-url}") private String backChannelLogoutUrl; + @Value("${server.reactive.session.cookie.max-age}") + private Duration springSessionDuration; + + @Value("${server.reactive.session.cookie.path}") + private String springSessionPath; + private ClassLoader loader; @Bean @@ -41,6 +53,23 @@ public class SecurityConfiguration implements BeanClassLoaderAware { c.pathMatchers("/api/**").authenticated(); }) .oauth2Login(c -> { + c.authenticationSuccessHandler((exchange, auth) -> { + ServerHttpResponse response = exchange.getExchange().getResponse(); + + response.getCookies().set( + IS_LOGGED_IN_COOKIE, + ResponseCookie.from(IS_LOGGED_IN_COOKIE) + .value("true") + .path(springSessionPath) + .maxAge(springSessionDuration) + .httpOnly(false) + .secure(false) + .build() + ); + + return new RedirectServerAuthenticationSuccessHandler("/").onAuthenticationSuccess(exchange, auth); + }); + c.authorizationRequestResolver(resolver); }) .logout(c -> { @@ -50,6 +79,17 @@ public class SecurityConfiguration implements BeanClassLoaderAware { response.setStatusCode(HttpStatus.SEE_OTHER); response.getHeaders().set("Location", backChannelLogoutUrl); response.getCookies().remove("JSESSIONID"); + response.getCookies().remove("SESSION"); + response.getCookies().set( + IS_LOGGED_IN_COOKIE, + ResponseCookie.from(IS_LOGGED_IN_COOKIE) + .value("false") + .path(springSessionPath) + .maxAge(springSessionDuration) + .httpOnly(false) + .secure(false) + .build() + ); return ex.getExchange().getSession().flatMap(WebSession::invalidate); }); diff --git a/APIGateway/src/main/resources/application.yml b/APIGateway/src/main/resources/application.yml index 522a9f2..7fd79e0 100644 --- a/APIGateway/src/main/resources/application.yml +++ b/APIGateway/src/main/resources/application.yml @@ -17,7 +17,7 @@ spring: redirect-uri: "{baseUrl}/login/oauth2/code/authentik" provider: authentik: - back-channel-logout-url: ${AUTHENTIK_BACK_CHANNEL_LOGOUT_URL} + back-channel-logout-url: ${AUTHENTIK_BACK_CHANNEL_LOGOUT_URL} # spring doesn't support back-channel logouts by default issuer-uri: ${AUTHENTIK_ISSUER_URL} cloud: gateway: @@ -26,7 +26,7 @@ spring: routes: - id: api uri: ${API_URI} - order: 0 + order: 1 predicates: - Path=/api/** filters: @@ -35,4 +35,19 @@ spring: order: 10 uri: ${FRONTEND_URI} predicates: - - Path=/** \ No newline at end of file + - Path=/** +server: + ssl: + enabled: true + key-store-type: PKCS12 + key-store: classpath:keystore/local.p12 + key-store-password: asdf1234 + key-alias: local + reactive: + session: + cookie: + http-only: true + secure: true + same-site: lax + max-age: 30m + path: "/" \ No newline at end of file diff --git a/PersonalFinancesService/.env.example b/PersonalFinancesService/.env.example index fb4ac75..42952cd 100644 --- a/PersonalFinancesService/.env.example +++ b/PersonalFinancesService/.env.example @@ -1,4 +1,4 @@ -JWT_ISSUER_URL= auth server configuration url for fetching JWKs +AUTHENTIK_ISSUER_URL= auth server configuration url for fetching JWKs ( dev: https://auth.mvvasilev.dev/application/o/personal-finances/ ) DATASOURCE_URL= database jdbc url ( postgres only, example: jdbc:postgresql://localhost:45093/mydatabase ) DATASOURCE_USER= database user diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/ApiController.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/ApiController.java index 36e7b1b..de353c2 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/ApiController.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/ApiController.java @@ -10,6 +10,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Optional; @@ -26,7 +27,7 @@ public class ApiController { this.objectMapper = objectMapper; } - @GetMapping("/api/user-info") + @PostMapping("/api/user-info") public ResponseEntity userInfo(JwtAuthenticationToken authenticationToken) { logger.info(authenticationToken.getToken().getClaimAsString(JwtClaimNames.SUB)); return ResponseEntity.of(Optional.of(SecurityContextHolder.getContext().getAuthentication())); diff --git a/frontend/src/app/Layout.jsx b/frontend/src/app/Layout.jsx index d04a90f..b48fbe6 100644 --- a/frontend/src/app/Layout.jsx +++ b/frontend/src/app/Layout.jsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { Link } from 'react-router-dom'; +import {Link} from 'react-router-dom'; import CssBaseline from '@mui/material/CssBaseline' -import { ThemeProvider, createTheme } from '@mui/material'; +import {ThemeProvider, createTheme} from '@mui/material'; import Box from '@mui/material/Box'; import Drawer from '@mui/material/Drawer'; import Divider from '@mui/material/Divider'; @@ -10,92 +10,128 @@ import ListItem from '@mui/material/ListItem'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; -import { Home as HomeIcon } from '@mui/icons-material'; -import { Receipt as TransactionsIcon } from '@mui/icons-material'; -import { Logout as LogoutIcon } from '@mui/icons-material'; -import { Toaster } from 'react-hot-toast'; +import {Home as HomeIcon} from '@mui/icons-material'; +import {Receipt as TransactionsIcon} from '@mui/icons-material'; +import {Logout as LogoutIcon} from '@mui/icons-material'; +import {Login as LoginIcon} from "@mui/icons-material"; +import {Toaster} from 'react-hot-toast'; import theme from '../components/ThemeRegistry/theme'; +import Button from "@mui/material/Button"; const DRAWER_WIDTH = 240; const NAV_LINKS = [ - { text: 'Home', to: '/', icon: HomeIcon }, - { text: 'Transactions', to: '/transactions', icon: TransactionsIcon }, + {text: 'Home', to: '/', icon: HomeIcon}, + {text: 'Transactions', to: '/transactions', icon: TransactionsIcon}, ]; const BOTTOM_LINKS = [ - { text: 'Logout', icon: LogoutIcon, href: "/logout" }, + // { + // text: 'Logout', + // icon: LogoutIcon, + // //href: "/logout", + // onClick: (e) => { + // console.log(e); + // } + // }, ]; -export default function RootLayout({ children }) { - return ( - - - - - {NAV_LINKS.map(({ text, to, icon: Icon }) => ( - - - - - - - - - ))} - - - - {BOTTOM_LINKS.map(({ text, icon: Icon, href }) => ( - - - - - - - - - ))} - - - - {children} - - - - ); +function getCookie(key) { + var b = document.cookie.match("(^|;)\\s*" + key + "\\s*=\\s*([^;]+)"); + return b ? b.pop() : ""; +} + +function isLoggedIn() { + return getCookie("isLoggedIn") === "true"; +} + +export default function RootLayout({children}) { + return ( + + + + + {NAV_LINKS.map(({text, to, icon: Icon}) => ( + + + + + + + + + ))} + + + + {BOTTOM_LINKS.map(({text, icon: Icon, href, onClick}) => ( + + + + + + + + + ))} + {!isLoggedIn() && + + + + + + + } + {isLoggedIn() &&
+ + + + + + + + +
} +
+
+ + {children} + + + + ); } diff --git a/frontend/src/app/pages/TransactionsPage.jsx b/frontend/src/app/pages/TransactionsPage.jsx index 5265ccf..9dee2c8 100644 --- a/frontend/src/app/pages/TransactionsPage.jsx +++ b/frontend/src/app/pages/TransactionsPage.jsx @@ -1,9 +1,19 @@ -import { AppBar, Stack } from "@mui/material"; +import Button from "@mui/material/Button"; +import { Publish as ImportExportIcon } from "@mui/icons-material"; export default function TransactionsPage() { return ( <> - Transactions + ); } \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 854f37c..bd26410 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -16,8 +16,5 @@ export default defineConfig({ server: { host: '127.0.0.1', port: 5173, - proxy: { - "/api": "http://localhost:8080" - } } })