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

3
.gitignore vendored
View file

@ -42,4 +42,5 @@ bin/
.DS_Store
### .env files ###
.env
.env
*.p12

View file

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

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

View file

@ -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=/**
- 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_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.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<Authentication> userInfo(JwtAuthenticationToken authenticationToken) {
logger.info(authenticationToken.getToken().getClaimAsString(JwtClaimNames.SUB));
return ResponseEntity.of(Optional.of(SecurityContextHolder.getContext().getAuthentication()));

View file

@ -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 (
<ThemeProvider theme={theme}>
<CssBaseline />
<Drawer
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box',
},
}}
variant="permanent"
anchor="left"
>
<List>
{NAV_LINKS.map(({ text, to, icon: Icon }) => (
<ListItem key={to} disablePadding>
<ListItemButton component={Link} to={to}>
<ListItemIcon>
<Icon />
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider sx={{ mt: 'auto' }} />
<List>
{BOTTOM_LINKS.map(({ text, icon: Icon, href }) => (
<ListItem key={text} disablePadding>
<ListItemButton href={href}>
<ListItemIcon>
<Icon />
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
</Drawer>
<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>
);
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 (
<ThemeProvider theme={theme}>
<CssBaseline/>
<Drawer
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box',
},
}}
variant="permanent"
anchor="left"
>
<List>
{NAV_LINKS.map(({text, to, icon: Icon}) => (
<ListItem key={to} disablePadding>
<ListItemButton component={Link} to={to}>
<ListItemIcon>
<Icon/>
</ListItemIcon>
<ListItemText primary={text}/>
</ListItemButton>
</ListItem>
))}
</List>
<Divider sx={{mt: 'auto'}}/>
<List>
{BOTTOM_LINKS.map(({text, icon: Icon, href, onClick}) => (
<ListItem key={text} disablePadding>
<ListItemButton type={"submit"} onClick={onClick} href={href}>
<ListItemIcon>
<Icon/>
</ListItemIcon>
<ListItemText primary={text}/>
</ListItemButton>
</ListItem>
))}
{!isLoggedIn() && <ListItem key="login-btn" disablePadding>
<ListItemButton component="button" href="/oauth2/authorization/authentik">
<ListItemIcon>
<LoginIcon/>
</ListItemIcon>
<ListItemText primary="Login"/>
</ListItemButton>
</ListItem>}
{isLoggedIn() && <form method="POST" action="/logout">
<ListItem key="logout-btn" disablePadding>
<ListItemButton component="button" type="submit">
<ListItemIcon>
<LogoutIcon/>
</ListItemIcon>
<ListItemText primary="Logout"/>
</ListItemButton>
</ListItem>
</form>}
</List>
</Drawer>
<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() {
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: {
host: '127.0.0.1',
port: 5173,
proxy: {
"/api": "http://localhost:8080"
}
}
})