Add SSL configs, configure session cookies ( mostly defaults ), fix login/logout on frontend

This commit is contained in:
Miroslav Vasilev 2023-12-14 23:56:59 +02:00
parent 6a5ea58e08
commit da3f2f6169
9 changed files with 199 additions and 91 deletions

1
.gitignore vendored
View file

@ -43,3 +43,4 @@ bin/
### .env files ### ### .env files ###
.env .env
*.p12

View file

@ -1,9 +1,17 @@
AUTHENTIK_CLIENT_ID= authentik oauth2 client id AUTHENTIK_CLIENT_ID= authentik oauth2 client id
AUTHENTIK_CLIENT_SECRET= authentik oauth2 client secret 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 GATEWAY_URI= http://localhost:8080
API_URI= http://localhost:8081 API_URI= http://localhost:8081
FRONTEND_URI= http://localhost:5173 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_HOST= the address of the redis host
REDIS_PORT= the port of redis ( 6379 by default ) REDIS_PORT= the port of redis ( 6379 by default )

View file

@ -8,6 +8,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity; 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.SecurityWebFilterChain;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint; import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint; 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.session.data.redis.config.annotation.web.server.EnableRedisWebSession;
import org.springframework.web.server.WebSession; import org.springframework.web.server.WebSession;
import java.time.Duration;
@Configuration @Configuration
@EnableWebFluxSecurity @EnableWebFluxSecurity
@EnableRedisWebSession @EnableRedisWebSession
public class SecurityConfiguration implements BeanClassLoaderAware { 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}") @Value("${spring.security.oauth2.client.provider.authentik.back-channel-logout-url}")
private String backChannelLogoutUrl; 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; private ClassLoader loader;
@Bean @Bean
@ -41,6 +53,23 @@ public class SecurityConfiguration implements BeanClassLoaderAware {
c.pathMatchers("/api/**").authenticated(); c.pathMatchers("/api/**").authenticated();
}) })
.oauth2Login(c -> { .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); c.authorizationRequestResolver(resolver);
}) })
.logout(c -> { .logout(c -> {
@ -50,6 +79,17 @@ public class SecurityConfiguration implements BeanClassLoaderAware {
response.setStatusCode(HttpStatus.SEE_OTHER); response.setStatusCode(HttpStatus.SEE_OTHER);
response.getHeaders().set("Location", backChannelLogoutUrl); response.getHeaders().set("Location", backChannelLogoutUrl);
response.getCookies().remove("JSESSIONID"); 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); return ex.getExchange().getSession().flatMap(WebSession::invalidate);
}); });

View file

@ -17,7 +17,7 @@ spring:
redirect-uri: "{baseUrl}/login/oauth2/code/authentik" redirect-uri: "{baseUrl}/login/oauth2/code/authentik"
provider: provider:
authentik: 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} issuer-uri: ${AUTHENTIK_ISSUER_URL}
cloud: cloud:
gateway: gateway:
@ -26,7 +26,7 @@ spring:
routes: routes:
- id: api - id: api
uri: ${API_URI} uri: ${API_URI}
order: 0 order: 1
predicates: predicates:
- Path=/api/** - Path=/api/**
filters: filters:
@ -36,3 +36,18 @@ spring:
uri: ${FRONTEND_URI} uri: ${FRONTEND_URI}
predicates: predicates:
- Path=/** - 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: "/"

View file

@ -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_URL= database jdbc url ( postgres only, example: jdbc:postgresql://localhost:45093/mydatabase )
DATASOURCE_USER= database user DATASOURCE_USER= database user

View file

@ -10,6 +10,7 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Optional; import java.util.Optional;
@ -26,7 +27,7 @@ public class ApiController {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@GetMapping("/api/user-info") @PostMapping("/api/user-info")
public ResponseEntity<Authentication> userInfo(JwtAuthenticationToken authenticationToken) { public ResponseEntity<Authentication> userInfo(JwtAuthenticationToken authenticationToken) {
logger.info(authenticationToken.getToken().getClaimAsString(JwtClaimNames.SUB)); logger.info(authenticationToken.getToken().getClaimAsString(JwtClaimNames.SUB));
return ResponseEntity.of(Optional.of(SecurityContextHolder.getContext().getAuthentication())); return ResponseEntity.of(Optional.of(SecurityContextHolder.getContext().getAuthentication()));

View file

@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { Link } from 'react-router-dom'; import {Link} from 'react-router-dom';
import CssBaseline from '@mui/material/CssBaseline' 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 Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer'; import Drawer from '@mui/material/Drawer';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
@ -10,92 +10,128 @@ import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import { Home as HomeIcon } from '@mui/icons-material'; import {Home as HomeIcon} from '@mui/icons-material';
import { Receipt as TransactionsIcon } from '@mui/icons-material'; import {Receipt as TransactionsIcon} from '@mui/icons-material';
import { Logout as LogoutIcon } from '@mui/icons-material'; import {Logout as LogoutIcon} from '@mui/icons-material';
import { Toaster } from 'react-hot-toast'; import {Login as LoginIcon} from "@mui/icons-material";
import {Toaster} from 'react-hot-toast';
import theme from '../components/ThemeRegistry/theme'; import theme from '../components/ThemeRegistry/theme';
import Button from "@mui/material/Button";
const DRAWER_WIDTH = 240; const DRAWER_WIDTH = 240;
const NAV_LINKS = [ const NAV_LINKS = [
{ text: 'Home', to: '/', icon: HomeIcon }, {text: 'Home', to: '/', icon: HomeIcon},
{ text: 'Transactions', to: '/transactions', icon: TransactionsIcon }, {text: 'Transactions', to: '/transactions', icon: TransactionsIcon},
]; ];
const BOTTOM_LINKS = [ 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 }) { function getCookie(key) {
return ( var b = document.cookie.match("(^|;)\\s*" + key + "\\s*=\\s*([^;]+)");
<ThemeProvider theme={theme}> return b ? b.pop() : "";
<CssBaseline /> }
<Drawer
sx={{ function isLoggedIn() {
width: DRAWER_WIDTH, return getCookie("isLoggedIn") === "true";
flexShrink: 0, }
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH, export default function RootLayout({children}) {
boxSizing: 'border-box', return (
}, <ThemeProvider theme={theme}>
}} <CssBaseline/>
variant="permanent" <Drawer
anchor="left" sx={{
> width: DRAWER_WIDTH,
<List> flexShrink: 0,
{NAV_LINKS.map(({ text, to, icon: Icon }) => ( '& .MuiDrawer-paper': {
<ListItem key={to} disablePadding> width: DRAWER_WIDTH,
<ListItemButton component={Link} to={to}> boxSizing: 'border-box',
<ListItemIcon> },
<Icon /> }}
</ListItemIcon> variant="permanent"
<ListItemText primary={text} /> anchor="left"
</ListItemButton> >
</ListItem> <List>
))} {NAV_LINKS.map(({text, to, icon: Icon}) => (
</List> <ListItem key={to} disablePadding>
<Divider sx={{ mt: 'auto' }} /> <ListItemButton component={Link} to={to}>
<List> <ListItemIcon>
{BOTTOM_LINKS.map(({ text, icon: Icon, href }) => ( <Icon/>
<ListItem key={text} disablePadding> </ListItemIcon>
<ListItemButton href={href}> <ListItemText primary={text}/>
<ListItemIcon> </ListItemButton>
<Icon /> </ListItem>
</ListItemIcon> ))}
<ListItemText primary={text} /> </List>
</ListItemButton> <Divider sx={{mt: 'auto'}}/>
</ListItem> <List>
))} {BOTTOM_LINKS.map(({text, icon: Icon, href, onClick}) => (
</List> <ListItem key={text} disablePadding>
</Drawer> <ListItemButton type={"submit"} onClick={onClick} href={href}>
<Box <ListItemIcon>
component="main" <Icon/>
sx={{ </ListItemIcon>
position: "absolute", <ListItemText primary={text}/>
top: 0, </ListItemButton>
flexGrow: 1, </ListItem>
bgcolor: 'background.default', ))}
ml: `${DRAWER_WIDTH}px`, {!isLoggedIn() && <ListItem key="login-btn" disablePadding>
p: 3, <ListItemButton component="button" href="/oauth2/authorization/authentik">
}} <ListItemIcon>
> <LoginIcon/>
{children} </ListItemIcon>
</Box> <ListItemText primary="Login"/>
<Toaster </ListItemButton>
toastOptions={{ </ListItem>}
success: { {isLoggedIn() && <form method="POST" action="/logout">
style: { <ListItem key="logout-btn" disablePadding>
background: '#dad7cd', <ListItemButton component="button" type="submit">
}, <ListItemIcon>
}, <LogoutIcon/>
error: { </ListItemIcon>
style: { <ListItemText primary="Logout"/>
background: '#ff8fab', </ListItemButton>
}, </ListItem>
}, </form>}
}} </List>
/> </Drawer>
</ ThemeProvider> <Box
); component="main"
sx={{
position: "absolute",
top: 0,
flexGrow: 1,
bgcolor: 'background.default',
ml: `${DRAWER_WIDTH}px`,
p: 3,
}}
>
{children}
</Box>
<Toaster
toastOptions={{
success: {
style: {
background: '#dad7cd',
},
},
error: {
style: {
background: '#ff8fab',
},
},
}}
/>
</ ThemeProvider>
);
} }

View file

@ -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() { export default function TransactionsPage() {
return ( return (
<> <>
<AppBar position="static">Transactions</AppBar> <Button variant="contained" onClick={() => {
fetch("/api/user-info", {
method: "POST"
})
.then((resp) => {
console.log(resp)
});
}}>
<ImportExportIcon /> Import
</Button>
</> </>
); );
} }

View file

@ -16,8 +16,5 @@ export default defineConfig({
server: { server: {
host: '127.0.0.1', host: '127.0.0.1',
port: 5173, port: 5173,
proxy: {
"/api": "http://localhost:8080"
}
} }
}) })