[release] v0.3.0-unstable 2FA + geoblock
|
@ -44,6 +44,12 @@ jobs:
|
|||
name: Install dependencies
|
||||
command: npm install
|
||||
|
||||
- run:
|
||||
name: Download GeoLite2-Country database
|
||||
command: |
|
||||
curl -s -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=$MAX_TOKEN&suffix=tar.gz" -o GeoLite2-Country.tar.gz
|
||||
tar -xzf GeoLite2-Country.tar.gz --strip-components 1 --wildcards "*.mmdb"
|
||||
|
||||
- run:
|
||||
name: Build UI
|
||||
command: npm run client-build
|
||||
|
|
1
.gitignore
vendored
|
@ -12,3 +12,4 @@ todo.txt
|
|||
LICENCE
|
||||
tokens.json
|
||||
.vscode
|
||||
GeoLite2-Country.mmdb
|
BIN
Logo.png
Before Width: | Height: | Size: 354 KiB After Width: | Height: | Size: 348 KiB |
1
build.sh
|
@ -4,6 +4,7 @@ if [ $? -ne 0 ]; then
|
|||
exit 1
|
||||
fi
|
||||
cp -r static build/
|
||||
cp -r GeoLite2-Country.mmdb build/
|
||||
mkdir build/images
|
||||
cp client/src/assets/images/icons/cosmos_gray.png build/cosmos_gray.png
|
||||
cp client/src/assets/images/icons/cosmos_gray.png cosmos_gray.png
|
||||
|
|
|
@ -67,6 +67,39 @@ function deleteUser(nickname) {
|
|||
}))
|
||||
}
|
||||
|
||||
function new2FA(nickname) {
|
||||
return wrap(fetch('/cosmos/api/mfa', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function check2FA(values) {
|
||||
return wrap(fetch('/cosmos/api/mfa', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: values
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
function reset2FA(values) {
|
||||
return wrap(fetch('/cosmos/api/mfa', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
nickname: values
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
export {
|
||||
list,
|
||||
create,
|
||||
|
@ -75,4 +108,7 @@ export {
|
|||
edit,
|
||||
get,
|
||||
deleteUser,
|
||||
new2FA,
|
||||
check2FA,
|
||||
reset2FA
|
||||
};
|
Before Width: | Height: | Size: 354 KiB After Width: | Height: | Size: 348 KiB |
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 141 KiB |
|
@ -55,3 +55,10 @@
|
|||
.code {
|
||||
background-color: rgba(0.2,0.2,0.2,0.2);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.MuiPopper-root > * {
|
||||
color:white;
|
||||
background-color: rgba(0,0,0,0.8);
|
||||
}
|
||||
}
|
|
@ -10,6 +10,10 @@ const IsLoggedIn = () => useEffect(() => {
|
|||
window.location.href = '/ui/newInstall';
|
||||
} else if (data.status == 'error' && data.code == "HTTP004") {
|
||||
window.location.href = '/ui/login';
|
||||
} else if (data.status == 'error' && data.code == "HTTP006") {
|
||||
window.location.href = '/ui/loginmfa';
|
||||
} else if (data.status == 'error' && data.code == "HTTP007") {
|
||||
window.location.href = '/ui/newmfa';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -9,7 +9,6 @@ import {
|
|||
FormControlLabel,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
Link,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
|
|
|
@ -72,8 +72,8 @@ const AuthRegister = ({nickname, isRegister, isInviteLink, regkey}) => {
|
|||
.max(255)
|
||||
.required('Password is required')
|
||||
.matches(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/,
|
||||
'Must Contain 8 Characters, One Uppercase, One Lowercase, One Number and one special case Character'
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{9,})/,
|
||||
'Must Contain 9 Characters, One Uppercase, One Lowercase, One Number and one special case Character'
|
||||
),
|
||||
})}
|
||||
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
|
|
197
client/src/pages/authentication/newMFA.jsx
Normal file
|
@ -0,0 +1,197 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
// material-ui
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
OutlinedInput,
|
||||
Stack,
|
||||
Typography,
|
||||
Alert,
|
||||
TextField,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
|
||||
// project import
|
||||
import AuthWrapper from './AuthWrapper';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
|
||||
import * as Yup from 'yup';
|
||||
import * as API from '../../api';
|
||||
|
||||
import QRCode from 'qrcode';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Formik } from 'formik';
|
||||
import { LoadingButton } from '@mui/lab';
|
||||
import { CosmosCollapse } from '../config/users/formShortcuts';
|
||||
|
||||
const MFALoginForm = () => {
|
||||
const urlSearchParams = new URLSearchParams(window.location.search);
|
||||
const redirectTo = urlSearchParams.get('redirect') ? urlSearchParams.get('redirect') : '/ui';
|
||||
|
||||
useEffect(() => {
|
||||
API.auth.me().then((data) => {
|
||||
if(data.status == 'OK') {
|
||||
window.location.href = redirectTo;
|
||||
} else if(data.status == 'NEW_INSTALL') {
|
||||
window.location.href = '/ui/newInstall';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return <Formik
|
||||
initialValues={{
|
||||
token: '',
|
||||
}}
|
||||
validationSchema={Yup.object().shape({
|
||||
token: Yup.string().required('Token is required').min(6, 'Token must be at least 6 characters').max(6, 'Token must be at most 6 characters'),
|
||||
})}
|
||||
onSubmit={(values, { setSubmitting, setStatus, setErrors }) => {
|
||||
API.users.check2FA(values.token).then((data) => {
|
||||
window.location.href = redirectTo;
|
||||
}).catch((error) => {
|
||||
console.log(error)
|
||||
setStatus({ success: false });
|
||||
setErrors({ submit: "Wrong OTP. Try again" });
|
||||
setSubmitting(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(formik) => (
|
||||
<form autoComplete="off" noValidate onSubmit={formik.handleSubmit}>
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
label="Token"
|
||||
{...formik.getFieldProps('token')}
|
||||
error={formik.touched.token && formik.errors.token && true}
|
||||
helperText={formik.touched.token && formik.errors.token && formik.errors.token}
|
||||
/>
|
||||
{formik.errors.submit && (
|
||||
<Grid item xs={12}>
|
||||
<FormHelperText error>{formik.errors.submit}</FormHelperText>
|
||||
</Grid>
|
||||
)}
|
||||
<LoadingButton
|
||||
fullWidth
|
||||
size="large"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
loading={formik.isSubmitting}
|
||||
>
|
||||
Login
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Formik>;
|
||||
}
|
||||
|
||||
const MFASetup = () => {
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const canvasRef = useRef(null);
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === 'dark';
|
||||
|
||||
const getCode = () => {
|
||||
return API.users.new2FA().then(({data}) => {
|
||||
if (data) {
|
||||
setMfaCode(data.key);
|
||||
QRCode.toCanvas(canvasRef.current, data.key, {
|
||||
width: 300,
|
||||
color: {
|
||||
dark: theme.palette.secondary.main,
|
||||
light: '#ffffff'
|
||||
}
|
||||
}, function (error) {
|
||||
if (error) console.error(error)
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getCode();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">This server requires 2FA. Scan this QR code with your <Tooltip title="For example FreeOTP(+) or Google/Microsoft authenticator"><span style={{cursor: 'pointer', textDecoration:"underline dotted"}}>authenticator app</span></Tooltip> to proceed</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} textAlign={'center'}>
|
||||
<canvas style={{borderRadius: '15px'}} ref={canvasRef} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">...Or enter this code manually in it</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<CosmosCollapse title="Show manual code" defaultExpanded={false}>
|
||||
<div style={{padding: '20px', fontSize: '90%', borderRadius: '15px', background: 'rgba(0,0,0,0.2)'}}>
|
||||
{mfaCode && <span>{mfaCode.split('?')[1].split('&').map(a => <div>{decodeURI(a).replace('=', ': ')}</div>)}</span>}
|
||||
</div>
|
||||
</CosmosCollapse>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">Once you have scanned the QR code or entered the code manually, enter the token from your authenticator app below</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<MFALoginForm />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Link to="/ui/logout">
|
||||
<Typography variant="h5">Logout</Typography>
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
const NewMFA = () => (
|
||||
<AuthWrapper>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: { xs: -0.5, sm: 0.5 } }}>
|
||||
<Typography variant="h3">New MFA Setup</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<MFASetup />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AuthWrapper>
|
||||
);
|
||||
|
||||
const MFALogin = () => (
|
||||
<AuthWrapper>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: { xs: -0.5, sm: 0.5 } }}>
|
||||
<Typography variant="h3">Enter your OTP</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<MFALoginForm />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AuthWrapper>
|
||||
);
|
||||
|
||||
export default NewMFA;
|
||||
|
||||
export {
|
||||
MFASetup,
|
||||
NewMFA,
|
||||
MFALogin,
|
||||
MFALoginForm
|
||||
};
|
|
@ -51,6 +51,7 @@ const NewRouteCreate = ({ openNewModal, setOpenNewModal, config }) => {
|
|||
AuthEnabled: false,
|
||||
Timeout: 14400000,
|
||||
ThrottlePerMinute: 10000,
|
||||
BlockCommonBots: true,
|
||||
SmartShield: {
|
||||
Enabled: true,
|
||||
}
|
||||
|
|
|
@ -5,12 +5,13 @@ import { Formik } from 'formik';
|
|||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
Grid,
|
||||
Stack,
|
||||
|
||||
} from '@mui/material';
|
||||
import RestartModal from '../users/restart';
|
||||
import { CosmosCheckbox, CosmosInputText } from '../users/formShortcuts';
|
||||
import { CosmosCheckbox, CosmosFormDivider, CosmosInputText, CosmosSelect } from '../users/formShortcuts';
|
||||
import { snackit } from '../../../api/wrap';
|
||||
|
||||
const RouteSecurity = ({ routeConfig }) => {
|
||||
|
@ -27,7 +28,16 @@ const RouteSecurity = ({ routeConfig }) => {
|
|||
ThrottlePerMinute: routeConfig.ThrottlePerMinute,
|
||||
CORSOrigin: routeConfig.CORSOrigin,
|
||||
MaxBandwith: routeConfig.MaxBandwith,
|
||||
BlockAPIAbuse: routeConfig.BlockAPIAbuse,
|
||||
BlockCommonBots: routeConfig.BlockCommonBots,
|
||||
_SmartShield_Enabled: (routeConfig.SmartShield ? routeConfig.SmartShield.Enabled : false),
|
||||
_SmartShield_PolicyStrictness: (routeConfig.SmartShield ? routeConfig.SmartShield.PolicyStrictness : 0),
|
||||
_SmartShield_PerUserTimeBudget: (routeConfig.SmartShield ? routeConfig.SmartShield.PerUserTimeBudget : 0),
|
||||
_SmartShield_PerUserRequestLimit: (routeConfig.SmartShield ? routeConfig.SmartShield.PerUserRequestLimit : 0),
|
||||
_SmartShield_PerUserByteLimit: (routeConfig.SmartShield ? routeConfig.SmartShield.PerUserByteLimit : 0),
|
||||
_SmartShield_PerUserSimultaneous: (routeConfig.SmartShield ? routeConfig.SmartShield.PerUserSimultaneous : 0),
|
||||
_SmartShield_MaxGlobalSimultaneous: (routeConfig.SmartShield ? routeConfig.SmartShield.MaxGlobalSimultaneous : 0),
|
||||
_SmartShield_PrivilegedGroups: (routeConfig.SmartShield ? routeConfig.SmartShield.PrivilegedGroups : []),
|
||||
}}
|
||||
onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
|
||||
const fullValues = {
|
||||
|
@ -38,8 +48,23 @@ const RouteSecurity = ({ routeConfig }) => {
|
|||
if(!fullValues.SmartShield) {
|
||||
fullValues.SmartShield = {};
|
||||
}
|
||||
|
||||
fullValues.SmartShield.Enabled = values._SmartShield_Enabled;
|
||||
delete fullValues._SmartShield_Enabled;
|
||||
fullValues.SmartShield.PolicyStrictness = values._SmartShield_PolicyStrictness;
|
||||
delete fullValues._SmartShield_PolicyStrictness;
|
||||
fullValues.SmartShield.PerUserTimeBudget = values._SmartShield_PerUserTimeBudget;
|
||||
delete fullValues._SmartShield_PerUserTimeBudget;
|
||||
fullValues.SmartShield.PerUserRequestLimit = values._SmartShield_PerUserRequestLimit;
|
||||
delete fullValues._SmartShield_PerUserRequestLimit;
|
||||
fullValues.SmartShield.PerUserByteLimit = values._SmartShield_PerUserByteLimit;
|
||||
delete fullValues._SmartShield_PerUserByteLimit;
|
||||
fullValues.SmartShield.PerUserSimultaneous = values._SmartShield_PerUserSimultaneous;
|
||||
delete fullValues._SmartShield_PerUserSimultaneous;
|
||||
fullValues.SmartShield.MaxGlobalSimultaneous = values._SmartShield_MaxGlobalSimultaneous;
|
||||
delete fullValues._SmartShield_MaxGlobalSimultaneous;
|
||||
fullValues.SmartShield.PrivilegedGroups = values._SmartShield_PrivilegedGroups;
|
||||
delete fullValues._SmartShield_PrivilegedGroups;
|
||||
|
||||
API.config.replaceRoute(routeConfig.Name, fullValues).then((res) => {
|
||||
if (res.status == "OK") {
|
||||
|
@ -64,18 +89,88 @@ const RouteSecurity = ({ routeConfig }) => {
|
|||
<Alert color='info'>Additional security settings. MFA and Captcha are not yet implemented.</Alert>
|
||||
</Grid>
|
||||
|
||||
<CosmosFormDivider title={'Authentication'} />
|
||||
|
||||
<CosmosCheckbox
|
||||
name="AuthEnabled"
|
||||
label="Authentication Required"
|
||||
formik={formik}
|
||||
/>
|
||||
|
||||
<CosmosFormDivider title={'Smart Shield'} />
|
||||
|
||||
<CosmosCheckbox
|
||||
name="_SmartShield_Enabled"
|
||||
label="Smart Shield Protection"
|
||||
formik={formik}
|
||||
/>
|
||||
|
||||
<CosmosSelect
|
||||
name="_SmartShield_PolicyStrictness"
|
||||
label="Policy Strictness"
|
||||
placeholder="Policy Strictness"
|
||||
options={[
|
||||
[0, 'Default'],
|
||||
[1, 'Strict'],
|
||||
[2, 'Normal'],
|
||||
[3, 'Lenient'],
|
||||
]}
|
||||
formik={formik}
|
||||
/>
|
||||
|
||||
<CosmosInputText
|
||||
name="_SmartShield_PerUserTimeBudget"
|
||||
label="Per User Time Budget in milliseconds (0 for default)"
|
||||
placeholder="Per User Time Budget"
|
||||
type="number"
|
||||
formik={formik}
|
||||
/>
|
||||
|
||||
<CosmosInputText
|
||||
name="_SmartShield_PerUserRequestLimit"
|
||||
label="Per User Request Limit (0 for default)"
|
||||
placeholder="Per User Request Limit"
|
||||
type="number"
|
||||
formik={formik}
|
||||
/>
|
||||
|
||||
<CosmosInputText
|
||||
name="_SmartShield_PerUserByteLimit"
|
||||
label="Per User Byte Limit (0 for default)"
|
||||
placeholder="Per User Byte Limit"
|
||||
type="number"
|
||||
formik={formik}
|
||||
/>
|
||||
|
||||
<CosmosInputText
|
||||
name="_SmartShield_PerUserSimultaneous"
|
||||
label="Per User Simultaneous Connections Limit (0 for default)"
|
||||
placeholder="Per User Simultaneous Connections Limit"
|
||||
type="number"
|
||||
formik={formik}
|
||||
/>
|
||||
|
||||
<CosmosInputText
|
||||
name="_SmartShield_MaxGlobalSimultaneous"
|
||||
label="Max Global Simultaneous Connections Limit (0 for default)"
|
||||
placeholder="Max Global Simultaneous Connections Limit"
|
||||
type="number"
|
||||
formik={formik}
|
||||
/>
|
||||
|
||||
<CosmosSelect
|
||||
name="_SmartShield_PrivilegedGroups"
|
||||
label="Privileged Groups (comma separated)"
|
||||
placeholder="Privileged Groups"
|
||||
options={[
|
||||
[0, 'Default'],
|
||||
[1, 'Users'],
|
||||
[2, 'Admin'],
|
||||
]}
|
||||
formik={formik}
|
||||
/>
|
||||
<CosmosFormDivider title={'Limits'} />
|
||||
|
||||
<CosmosInputText
|
||||
name="Timeout"
|
||||
label="Timeout in milliseconds (0 for no timeout, at least 30000 or less recommended)"
|
||||
|
@ -106,6 +201,18 @@ const RouteSecurity = ({ routeConfig }) => {
|
|||
placeholder="CORS Origin"
|
||||
formik={formik}
|
||||
/>
|
||||
|
||||
<CosmosCheckbox
|
||||
name="BlockCommonBots"
|
||||
label="Block Common Bots (Recommended)"
|
||||
formik={formik}
|
||||
/>
|
||||
|
||||
<CosmosCheckbox
|
||||
name="BlockAPIAbuse"
|
||||
label="Block requests without Referer header"
|
||||
formik={formik}
|
||||
/>
|
||||
</Grid>
|
||||
</MainCard>
|
||||
<MainCard ><Button
|
||||
|
|
|
@ -28,7 +28,7 @@ import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
|
|||
import AnimateButton from '../../../components/@extended/AnimateButton';
|
||||
import RestartModal from './restart';
|
||||
import { WarningOutlined, PlusCircleOutlined, CopyOutlined, ExclamationCircleOutlined , SyncOutlined, UserOutlined, KeyOutlined } from '@ant-design/icons';
|
||||
import { CosmosInputText, CosmosSelect } from './formShortcuts';
|
||||
import { CosmosCheckbox, CosmosInputText, CosmosSelect } from './formShortcuts';
|
||||
|
||||
|
||||
const ConfigManagement = () => {
|
||||
|
@ -56,6 +56,7 @@ const ConfigManagement = () => {
|
|||
initialValues={{
|
||||
MongoDB: config.MongoDB,
|
||||
LoggingLevel: config.LoggingLevel,
|
||||
RequireMFA: config.RequireMFA,
|
||||
|
||||
Hostname: config.HTTPConfig.Hostname,
|
||||
GenerateMissingTLSCert: config.HTTPConfig.GenerateMissingTLSCert,
|
||||
|
@ -64,6 +65,7 @@ const ConfigManagement = () => {
|
|||
HTTPSPort: config.HTTPConfig.HTTPSPort,
|
||||
SSLEmail: config.HTTPConfig.SSLEmail,
|
||||
HTTPSCertificateMode: config.HTTPConfig.HTTPSCertificateMode,
|
||||
DNSChallengeProvider: config.HTTPConfig.DNSChallengeProvider,
|
||||
}}
|
||||
validationSchema={Yup.object().shape({
|
||||
Hostname: Yup.string().max(255).required('Hostname is required'),
|
||||
|
@ -76,6 +78,7 @@ const ConfigManagement = () => {
|
|||
...config,
|
||||
MongoDB: values.MongoDB,
|
||||
LoggingLevel: values.LoggingLevel,
|
||||
RequireMFA: values.RequireMFA,
|
||||
HTTPConfig: {
|
||||
...config.HTTPConfig,
|
||||
Hostname: values.Hostname,
|
||||
|
@ -84,6 +87,7 @@ const ConfigManagement = () => {
|
|||
HTTPSPort: values.HTTPSPort,
|
||||
SSLEmail: values.SSLEmail,
|
||||
HTTPSCertificateMode: values.HTTPSCertificateMode,
|
||||
DNSChallengeProvider: values.DNSChallengeProvider,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -117,6 +121,14 @@ const ConfigManagement = () => {
|
|||
<Grid item xs={12}>
|
||||
<Alert severity="info">This page allow you to edit the configuration file. Any Environment Variable overwritting configuration won't appear here.</Alert>
|
||||
</Grid>
|
||||
|
||||
<CosmosCheckbox
|
||||
label="Force Multi-Factor Authentication"
|
||||
name="RequireMFA"
|
||||
formik={formik}
|
||||
helperText="Require MFA for all users"
|
||||
/>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel htmlFor="MongoDB-login">MongoDB connection string. It is advised to use Environment variable to store this securely instead. (Optional)</InputLabel>
|
||||
|
@ -266,7 +278,7 @@ const ConfigManagement = () => {
|
|||
]}
|
||||
/>
|
||||
|
||||
{
|
||||
{
|
||||
formik.values.HTTPSCertificateMode === "LETSENCRYPT" && (
|
||||
<CosmosInputText
|
||||
name="SSLEmail"
|
||||
|
@ -276,6 +288,16 @@ const ConfigManagement = () => {
|
|||
)
|
||||
}
|
||||
|
||||
{
|
||||
formik.values.HTTPSCertificateMode === "LETSENCRYPT" && (
|
||||
<CosmosInputText
|
||||
name="DNSChallengeProvider"
|
||||
label="DNS provider (if you are using a DNS Challenge)"
|
||||
formik={formik}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
|
||||
<Field
|
||||
|
|
|
@ -138,7 +138,7 @@ const UserManagement = () => {
|
|||
}).then(() => {
|
||||
setOpenCreateForm(false);
|
||||
refresh();
|
||||
sendlink(document.getElementById('c-nickname').value, 'create');
|
||||
sendlink(document.getElementById('c-nickname').value, 2);
|
||||
});
|
||||
}}>Create</Button>
|
||||
</DialogActions>
|
||||
|
@ -232,7 +232,14 @@ const UserManagement = () => {
|
|||
setToAction(r.nickname);
|
||||
setOpenDeleteForm(true);
|
||||
}
|
||||
}>Delete</Button></>
|
||||
}>Delete</Button>
|
||||
<Button variant="contained" color="error" onClick={
|
||||
() => {
|
||||
API.users.reset2FA(r.nickname).then(() => {
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
}>Reset 2FA</Button></>
|
||||
}
|
||||
},
|
||||
]}
|
||||
|
|
|
@ -325,8 +325,10 @@ const NewInstall = () => {
|
|||
email: '',
|
||||
}}
|
||||
validationSchema={Yup.object().shape({
|
||||
nickname: Yup.string().required('Nickname is required').min(3).max(32),
|
||||
password: Yup.string().required('Password is required').min(8).max(128).matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/, 'Password must contain at least 1 lowercase, 1 uppercase, 1 number, and 1 special character'),
|
||||
// nickname cant be admin or root
|
||||
nickname: Yup.string().required('Nickname is required').min(3).max(32)
|
||||
.matches(/^(?!admin|root).*$/, 'Nickname cannot be admin or root'),
|
||||
password: Yup.string().required('Password is required').min(8).max(128).matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{9,})/, 'Password must contain 9 characters: at least 1 lowercase, 1 uppercase, 1 number, and 1 special character'),
|
||||
email: Yup.string().email('Must be a valid email').max(255),
|
||||
confirmPassword: Yup.string().oneOf([Yup.ref('password'), null], 'Passwords must match'),
|
||||
})}
|
||||
|
|
|
@ -157,6 +157,7 @@ const ServeApps = () => {
|
|||
AuthEnabled: false,
|
||||
Timeout: 14400000,
|
||||
ThrottlePerMinute: 10000,
|
||||
BlockCommonBots: true,
|
||||
SmartShield: {
|
||||
Enabled: true,
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ import MinimalLayout from '../layout/MinimalLayout';
|
|||
import Logout from '../pages/authentication/Logoff';
|
||||
import NewInstall from '../pages/newInstall/newInstall';
|
||||
|
||||
import {NewMFA, MFALogin} from '../pages/authentication/newMFA';
|
||||
|
||||
// render - login
|
||||
const AuthLogin = Loadable(lazy(() => import('../pages/authentication/Login')));
|
||||
const AuthRegister = Loadable(lazy(() => import('../pages/authentication/Register')));
|
||||
|
@ -31,9 +33,16 @@ const LoginRoutes = {
|
|||
},
|
||||
{
|
||||
path: '/ui/newInstall',
|
||||
// redirect to /ui
|
||||
element: <NewInstall />
|
||||
},
|
||||
{
|
||||
path: '/ui/newmfa',
|
||||
element: <NewMFA />
|
||||
},
|
||||
{
|
||||
path: '/ui/loginmfa',
|
||||
element: <MFALogin />
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
|
|
|
@ -36,6 +36,6 @@ export default function ComponentsOverrides(theme) {
|
|||
Tab(theme),
|
||||
TableCell(theme),
|
||||
Tabs(),
|
||||
Typography()
|
||||
Typography(),
|
||||
);
|
||||
}
|
||||
|
|
BIN
cosmos_gray.png
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 141 KiB |
|
@ -12,6 +12,7 @@ WORKDIR /app
|
|||
|
||||
COPY build/cosmos .
|
||||
COPY build/cosmos_gray.png .
|
||||
COPY build/GeoLite2-Country.mmdb .
|
||||
COPY build/meta.json .
|
||||
COPY static ./static
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ WORKDIR /app
|
|||
|
||||
COPY build/cosmos .
|
||||
COPY build/cosmos_gray.png .
|
||||
COPY build/GeoLite2-Country.mmdb .
|
||||
COPY build/meta.json .
|
||||
COPY static ./static
|
||||
|
||||
|
|
BIN
favicon.ico
Before Width: | Height: | Size: 5.3 KiB |
4
go.mod
|
@ -104,12 +104,14 @@ require (
|
|||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.0.2 // indirect
|
||||
github.com/oracle/oci-go-sdk v24.3.0+incompatible // indirect
|
||||
github.com/oschwald/geoip2-golang v1.8.0 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.10.0 // indirect
|
||||
github.com/ovh/go-ovh v1.1.0 // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/pquerna/otp v1.3.0 // indirect
|
||||
github.com/pquerna/otp v1.4.0 // indirect
|
||||
github.com/sacloud/libsacloud v1.36.2 // indirect
|
||||
github.com/sirupsen/logrus v1.7.0 // indirect
|
||||
github.com/stretchr/objx v0.5.0 // indirect
|
||||
|
|
6
go.sum
|
@ -465,6 +465,10 @@ github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJ
|
|||
github.com/oracle/oci-go-sdk v24.2.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888=
|
||||
github.com/oracle/oci-go-sdk v24.3.0+incompatible h1:x4mcfb4agelf1O4/1/auGlZ1lr97jXRSSN5MxTgG/zU=
|
||||
github.com/oracle/oci-go-sdk v24.3.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888=
|
||||
github.com/oschwald/geoip2-golang v1.8.0 h1:KfjYB8ojCEn/QLqsDU0AzrJ3R5Qa9vFlx3z6SLNcKTs=
|
||||
github.com/oschwald/geoip2-golang v1.8.0/go.mod h1:R7bRvYjOeaoenAp9sKRS8GX5bJWcZ0laWO5+DauEktw=
|
||||
github.com/oschwald/maxminddb-golang v1.10.0 h1:Xp1u0ZhqkSuopaKmk1WwHtjF0H9Hd9181uj2MQ5Vndg=
|
||||
github.com/oschwald/maxminddb-golang v1.10.0/go.mod h1:Y2ELenReaLAZ0b400URyGwvYxHV1dLIxBuyOsyYjHK0=
|
||||
github.com/ovh/go-ovh v1.1.0 h1:bHXZmw8nTgZin4Nv7JuaLs0KG5x54EQR7migYTd1zrk=
|
||||
github.com/ovh/go-ovh v1.1.0/go.mod h1:AxitLZ5HBRPyUd+Zl60Ajaag+rNTdVXWIkzfrVuTXWA=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
|
@ -482,6 +486,8 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:Om
|
|||
github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs=
|
||||
github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
|
|
2138
package-lock.json
generated
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cosmos-server",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0-unstable",
|
||||
"description": "",
|
||||
"main": "test-server.js",
|
||||
"bugs": {
|
||||
|
@ -27,6 +27,7 @@
|
|||
"history": "^5.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-apexcharts": "^1.4.0",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
|
|
28
readme.md
|
@ -23,12 +23,13 @@ Whether you have a **server**, a **NAS**, or a **Raspberry Pi** with application
|
|||
|
||||
* **Easy to use** 🚀👍 to install and use, with a simple web UI to manage your applications
|
||||
* **User-friendly** 🧑🎨 For both new and experienced users: easily integrates into your existing home server (even with NGinx, Traefik, Portainer, etc...), the already existing applications you have, and the new ones you want to install
|
||||
* **SmartShield technology** 🧠🛡 Automatically secure your applications without manual adjustments (see below for more details)
|
||||
* **Secure Authentication** 👦👩 Connect to all your applications with the same account, including **strong security** and **multi-factor authentication**
|
||||
* **Latest Encryption Methods** 🔒🔑 To encrypt your data and protect your privacy. Security by design, and not as an afterthought
|
||||
* **Reverse Proxy** 🔄🔗 Reverse Proxy included, with a UI to easily manage your applications and their settings
|
||||
* **Automatic HTTPS** 🔑📜 certificates provisioning with Certbot / Let's Encrypt
|
||||
* **Anti-Bot** 🤖❌ protections such as Captcha and IP rate limiting
|
||||
* **Anti-DDOS** 🔥⛔️ protections such as variable timeouts/throttling, IP rate limiting and IP blacklisting
|
||||
* **Anti-Bot** 🤖❌ Collection of tools to prevent bots from accessing your applications, such as common bot detection, IP based detection, and more
|
||||
* **Anti-DDOS** 🔥⛔️ Additional protections such as variable timeouts/throttling, IP rate limiting and geo-blacklisting
|
||||
* **Proper User Management** 🪪 ❎ to invite your friends and family to your applications without awkardly sharing credentials. Let them request a password change with an email rather than having you unlock their account manually!
|
||||
* **Container Management** 🐋🔧 to easily manage your containers and their settings, keep them up to date as well as audit their security.
|
||||
* **Modular** 🧩📦 to easily add new features and integrations, but also run only the features you need (for example No docker, no Databases, or no HTTPS)
|
||||
|
@ -38,8 +39,21 @@ And a **lot more planned features** are coming!
|
|||
|
||||
![schema](./schema.png)
|
||||
|
||||
# What is the SmartShield?
|
||||
|
||||
# Why use it?
|
||||
SmartShield is a modern API protection package designed to secure your API by implementing advanced rate-limiting and user restrictions. This helps efficiently allocate and protect your resources without manual adjustment of limits and policies.
|
||||
|
||||
Key Features:
|
||||
|
||||
* **Dynamic Rate Limiting** ✨ SmartShield calculates rate limits based on user behavior, providing a flexible approach to maintain API health without negatively impacting user experience.
|
||||
* **Adaptive Actions** 📈 SmartShield automatically throttles users who exceed their rate limits, preventing them from consuming more resources than they are allowed without abruptly terminating their requests.
|
||||
* **User Bans & Strikes** 🚫 Implement temporary or permanent bans and issue strikes automatically to prevent API abuse from malicious or resource-intensive users.
|
||||
* **Global Request Control** 🌐 Monitor and limit the total number of simultaneous requests on your server, ensuring optimal performance and stability.
|
||||
* **User-based Metrics** 📊 SmartShield tracks user consumption in terms of requests, data usage, and simultaneous connections, allowing for detailed control.
|
||||
* **Privileged Access** 🔑 Assign privileged access to specific user groups, granting them exemption from certain restrictions and ensuring uninterrupted service even durin attacks.
|
||||
* **Customizable Policies** ⚙️ Modify SmartShield's default policies to suit your specific needs, such as request limits, time budgets, and more.
|
||||
|
||||
# Why use Cosmos?
|
||||
|
||||
If you have your own self-hosted data, such as a Plex server, or may be your own photo server, **you expose your data to being hacked, or your server to being highjacked** (even on your **local network**!).
|
||||
|
||||
|
@ -67,6 +81,10 @@ If you have any further questions, feel free to join our [Discord](https://disco
|
|||
Disclaimer: Cosmos is still in early Alpha stage, please be careful when you use it. It is not (yet, at least ;p) a replacement for proper control and mindfulness of your own security.
|
||||
```
|
||||
|
||||
# Let's Encrypt
|
||||
|
||||
Cosmos Server can automatically generate and renews HTTPS certificates for your applications using Let's Encrypt. It is compatible with wildcard certificates, using the DNS challenge. In order to do it, you need to add `DNSChallengeProvider` to the `HTTPConfig` in your config (or in the UI). And then add the proper API token via environment variables. To know what providers are supported and what environment variable they need, please refer to [this page](https://go-acme.github.io/lego/dns/#dns-providers).
|
||||
|
||||
# As A Developer
|
||||
|
||||
**If you're a self-hosted application developer**, integrate your application with Cosmos and enjoy **secure authentication**, **robust HTTP layer protection**, **HTTPS support**, **user management**, **encryption**, **logging**, **backup**, and more - all with **minimal effort**. And if your users prefer **not to install** Cosmos, your application will **still work seamlessly**.
|
||||
|
@ -87,7 +105,9 @@ make sure you expose the right ports (by default 80 / 443). It is best to keep t
|
|||
|
||||
You also need to keep the docker socket mounted, as Cosmos needs to be able to manage your containers.
|
||||
|
||||
you can use `latest-arm64` for arm architecture (ex: NAS or Raspberry)
|
||||
you can use `latest-arm64` for arm architecture (ex: NAS or Raspberry).
|
||||
|
||||
Finally, if you are using Cosmos from one of the countries considered "high risk," you can prevent Cosmos from blocking your IP by adding the following environment variable to your Docker run command: `-e COSMOS_SERVER_COUNTRY=IN`. Replace "IN" with your country code. The following countries are blocked by default: China (CN), Russia (RU), Turkey (TR), Brazil (BR), Bangladesh (BD), India (IN), Nepal (NP), Pakistan (PK), Sri Lanka (LK), Vietnam (VN), Indonesia (ID), Iran (IR), Iraq (IQ), Egypt (EG), Afghanistan (AF), and Romania (RO). Please note that this choice is neither political nor personal; it is solely based on past attack statistics. If you are from one of these countries and want to use Cosmos, we sincerely apologize for the inconvenience. If you are having issues with this, please contact us on Discord!
|
||||
|
||||
You can tweak the config file accordingly. Some settings can be changed before end with env var. [see here](https://github.com/azukaar/Cosmos-Server/wiki/Configuration).
|
||||
|
||||
|
|
|
@ -44,6 +44,11 @@ func startHTTPSServer(router *mux.Router, tlsCert string, tlsKey string) {
|
|||
cfg.SSLEmail = config.HTTPConfig.SSLEmail
|
||||
cfg.HTTPAddress = serverHostname+":"+serverPortHTTP
|
||||
cfg.TLSAddress = serverHostname+":"+serverPortHTTPS
|
||||
|
||||
if config.HTTPConfig.DNSChallengeProvider != "" {
|
||||
cfg.DNSProvider = config.HTTPConfig.DNSChallengeProvider
|
||||
}
|
||||
|
||||
cfg.FailedToRenewCertificate = func(err error) {
|
||||
utils.Error("Failed to renew certificate", err)
|
||||
}
|
||||
|
@ -119,8 +124,9 @@ func startHTTPSServer(router *mux.Router, tlsCert string, tlsKey string) {
|
|||
func tokenMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
//Header.Del
|
||||
r.Header.Set("x-cosmos-user", "")
|
||||
r.Header.Set("x-cosmos-role", "")
|
||||
r.Header.Del("x-cosmos-user")
|
||||
r.Header.Del("x-cosmos-role")
|
||||
r.Header.Del("x-cosmos-mfa")
|
||||
|
||||
u, err := user.RefreshUserToken(w, r)
|
||||
|
||||
|
@ -130,6 +136,7 @@ func tokenMiddleware(next http.Handler) http.Handler {
|
|||
|
||||
r.Header.Set("x-cosmos-user", u.Nickname)
|
||||
r.Header.Set("x-cosmos-role", strconv.Itoa((int)(u.Role)))
|
||||
r.Header.Set("x-cosmos-mfa", strconv.Itoa((int)(u.MFAState)))
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
|
@ -137,19 +144,27 @@ func tokenMiddleware(next http.Handler) http.Handler {
|
|||
|
||||
func StartServer() {
|
||||
baseMainConfig := utils.GetBaseMainConfig()
|
||||
config := utils.GetMainConfig().HTTPConfig
|
||||
serverPortHTTP = config.HTTPPort
|
||||
serverPortHTTPS = config.HTTPSPort
|
||||
config := utils.GetMainConfig()
|
||||
HTTPConfig := config.HTTPConfig
|
||||
serverPortHTTP = HTTPConfig.HTTPPort
|
||||
serverPortHTTPS = HTTPConfig.HTTPSPort
|
||||
|
||||
var tlsCert = config.TLSCert
|
||||
var tlsKey= config.TLSKey
|
||||
var tlsCert = HTTPConfig.TLSCert
|
||||
var tlsKey= HTTPConfig.TLSKey
|
||||
|
||||
if((tlsCert == "" || tlsKey == "") && config.HTTPSCertificateMode == utils.HTTPSCertModeList["SELFSIGNED"]) {
|
||||
domains := utils.GetAllHostnames()
|
||||
oldDomains := baseMainConfig.HTTPConfig.TLSKeyHostsCached
|
||||
|
||||
NeedsRefresh := (tlsCert == "" || tlsKey == "") || !utils.StringArrayEquals(domains, oldDomains)
|
||||
|
||||
if(NeedsRefresh && HTTPConfig.HTTPSCertificateMode == utils.HTTPSCertModeList["SELFSIGNED"]) {
|
||||
utils.Log("Generating new TLS certificate")
|
||||
pub, priv := utils.GenerateRSAWebCertificates()
|
||||
pub, priv := utils.GenerateRSAWebCertificates(domains)
|
||||
|
||||
baseMainConfig.HTTPConfig.TLSCert = pub
|
||||
baseMainConfig.HTTPConfig.TLSKey = priv
|
||||
baseMainConfig.HTTPConfig.TLSKeyHostsCached = domains
|
||||
|
||||
utils.SetBaseMainConfig(baseMainConfig)
|
||||
|
||||
utils.Log("Saved new TLS certificate")
|
||||
|
@ -158,7 +173,7 @@ func StartServer() {
|
|||
tlsKey = priv
|
||||
}
|
||||
|
||||
if ((config.AuthPublicKey == "" || config.AuthPrivateKey == "") && config.GenerateMissingAuthCert) {
|
||||
if ((HTTPConfig.AuthPublicKey == "" || HTTPConfig.AuthPrivateKey == "") && HTTPConfig.GenerateMissingAuthCert) {
|
||||
utils.Log("Generating new Auth ED25519 certificate")
|
||||
pub, priv := utils.GenerateEd25519Certificates()
|
||||
|
||||
|
@ -177,6 +192,10 @@ func StartServer() {
|
|||
router.Use(middleware.Logger)
|
||||
router.Use(utils.SetSecurityHeaders)
|
||||
|
||||
if config.BlockedCountries != nil && len(config.BlockedCountries) > 0 {
|
||||
router.Use(utils.BlockByCountryMiddleware(config.BlockedCountries))
|
||||
}
|
||||
|
||||
srapi := router.PathPrefix("/cosmos").Subrouter()
|
||||
|
||||
srapi.HandleFunc("/api/status", StatusRoute)
|
||||
|
@ -188,6 +207,7 @@ func StartServer() {
|
|||
srapi.HandleFunc("/api/register", user.UserRegister)
|
||||
srapi.HandleFunc("/api/invite", user.UserResendInviteLink)
|
||||
srapi.HandleFunc("/api/me", user.Me)
|
||||
srapi.HandleFunc("/api/mfa", user.API2FA)
|
||||
srapi.HandleFunc("/api/config", configapi.ConfigRoute)
|
||||
srapi.HandleFunc("/api/restart", configapi.ConfigApiRestart)
|
||||
|
||||
|
@ -201,9 +221,15 @@ func StartServer() {
|
|||
srapi.Use(proxy.SmartShieldMiddleware(
|
||||
utils.SmartShieldPolicy{
|
||||
Enabled: true,
|
||||
PerUserSimultaneous: 3,
|
||||
MaxGlobalSimultaneous: 12,
|
||||
PolicyStrictness: 1,
|
||||
PerUserRequestLimit: 5000,
|
||||
},
|
||||
))
|
||||
srapi.Use(utils.MiddlewareTimeout(20 * time.Second))
|
||||
srapi.Use(utils.BlockPostWithoutReferer)
|
||||
srapi.Use(proxy.BotDetectionMiddleware)
|
||||
srapi.Use(httprate.Limit(60, 1*time.Minute,
|
||||
httprate.WithKeyFuncs(httprate.KeyByIP),
|
||||
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -223,14 +249,14 @@ func StartServer() {
|
|||
fs := spa.SpaHandler(pwd + "/static", "index.html")
|
||||
router.PathPrefix("/ui").Handler(http.StripPrefix("/ui", fs))
|
||||
|
||||
router = proxy.BuildFromConfig(router, config.ProxyConfig)
|
||||
router = proxy.BuildFromConfig(router, HTTPConfig.ProxyConfig)
|
||||
|
||||
router.HandleFunc("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui", http.StatusMovedPermanently)
|
||||
}))
|
||||
|
||||
if ((config.HTTPSCertificateMode == utils.HTTPSCertModeList["SELFSIGNED"] || config.HTTPSCertificateMode == utils.HTTPSCertModeList["PROVIDED"]) &&
|
||||
tlsCert != "" && tlsKey != "") || (config.HTTPSCertificateMode == utils.HTTPSCertModeList["LETSENCRYPT"]) {
|
||||
if ((HTTPConfig.HTTPSCertificateMode == utils.HTTPSCertModeList["SELFSIGNED"] || HTTPConfig.HTTPSCertificateMode == utils.HTTPSCertModeList["PROVIDED"]) &&
|
||||
tlsCert != "" && tlsKey != "") || (HTTPConfig.HTTPSCertificateMode == utils.HTTPSCertModeList["LETSENCRYPT"]) {
|
||||
utils.Log("TLS certificate exist, starting HTTPS servers and redirecting HTTP to HTTPS")
|
||||
startHTTPSServer(router, tlsCert, tlsKey)
|
||||
} else {
|
||||
|
|
|
@ -36,7 +36,7 @@ type NewInstallJSON struct {
|
|||
|
||||
type AdminJSON struct {
|
||||
Nickname string `validate:"required,min=3,max=32,alphanum"`
|
||||
Password string `validate:"required,min=8,max=128,containsany=!@#$%^&*()_+,containsany=ABCDEFGHIJKLMNOPQRSTUVWXYZ,containsany=abcdefghijklmnopqrstuvwxyz,containsany=0123456789"`
|
||||
Password string `validate:"required,min=9,max=128,containsany=!@#$%^&*()_+,containsany=ABCDEFGHIJKLMNOPQRSTUVWXYZ,containsany=abcdefghijklmnopqrstuvwxyz,containsany=0123456789"`
|
||||
}
|
||||
|
||||
func NewInstallRoute(w http.ResponseWriter, req *http.Request) {
|
||||
|
|
|
@ -23,6 +23,8 @@ type SmartResponseWriterWrapper struct {
|
|||
shield smartShieldState
|
||||
policy utils.SmartShieldPolicy
|
||||
isOver bool
|
||||
hasBeenInterrupted bool
|
||||
isPrivileged bool
|
||||
}
|
||||
|
||||
func (w *SmartResponseWriterWrapper) IsOver() bool {
|
||||
|
@ -49,26 +51,47 @@ func (w *SmartResponseWriterWrapper) WriteHeader(status int) {
|
|||
if w.Status >= 400 {
|
||||
w.RequestCost *= 30
|
||||
}
|
||||
if !w.IsOver() {
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SmartResponseWriterWrapper) Write(p []byte) (int, error) {
|
||||
userConsumed := shield.GetUserUsedBudgets(w.ClientID)
|
||||
if !shield.isAllowedToReqest(w.policy, userConsumed) {
|
||||
utils.Log(fmt.Sprintf("SmartShield: %s is banned", w.ClientID))
|
||||
if !w.isPrivileged && !shield.isAllowedToReqest(w.policy, userConsumed) {
|
||||
utils.Log(fmt.Sprintf("SmartShield: %s has been blocked due to abuse", w.ClientID))
|
||||
w.isOver = true
|
||||
w.TimeEnded = time.Now()
|
||||
w.hasBeenInterrupted = true
|
||||
w.ResponseWriter.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.ResponseWriter.(http.Flusher).Flush()
|
||||
return 0, errors.New("Pending request cancelled due to SmartShield")
|
||||
}
|
||||
thro := shield.computeThrottle(w.policy, userConsumed)
|
||||
thro := 0
|
||||
|
||||
if !w.isPrivileged {
|
||||
shield.computeThrottle(w.policy, userConsumed)
|
||||
}
|
||||
|
||||
// initial throttle
|
||||
if w.ThrottleNext > 0 {
|
||||
time.Sleep(time.Duration(w.ThrottleNext) * time.Millisecond)
|
||||
}
|
||||
w.ThrottleNext = 0
|
||||
|
||||
// ongoing throttle
|
||||
if thro > 0 {
|
||||
time.Sleep(time.Duration(thro) * time.Millisecond)
|
||||
}
|
||||
|
||||
n, err := w.ResponseWriter.Write(p)
|
||||
|
||||
if err != nil {
|
||||
w.isOver = true
|
||||
w.TimeEnded = time.Now()
|
||||
w.hasBeenInterrupted = true
|
||||
}
|
||||
|
||||
w.Bytes += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
|
45
src/proxy/botblock.go
Normal file
|
@ -0,0 +1,45 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var botUserAgents = []string{
|
||||
"360Spider", "acapbot", "acoonbot", "ahrefs", "alexibot", "asterias", "attackbot", "backdorbot", "becomebot", "binlar",
|
||||
"blackwidow", "blekkobot", "blexbot", "blowfish", "bullseye", "bunnys", "butterfly", "careerbot", "casper", "checkpriv",
|
||||
"cheesebot", "cherrypick", "chinaclaw", "choppy", "clshttp", "cmsworld", "copernic", "copyrightcheck", "cosmos", "crescent",
|
||||
"cy_cho", "datacha", "demon", "diavol", "discobot", "dittospyder", "dotbot", "dotnetdotcom", "dumbot", "emailcollector",
|
||||
"emailsiphon", "emailwolf", "exabot", "extract", "eyenetie", "feedfinder", "flaming", "flashget", "flicky", "foobot",
|
||||
"g00g1e", "getright", "gigabot", "go-ahead-got", "gozilla", "grabnet", "grafula", "harvest", "heritrix", "httrack",
|
||||
"icarus6j", "jetbot", "jetcar", "jikespider", "kmccrew", "leechftp", "libweb", "linkextractor", "linkscan", "linkwalker",
|
||||
"loader", "masscan", "miner", "majestic", "mechanize", "mj12bot", "morfeus", "moveoverbot", "netmechanic", "netspider",
|
||||
"nicerspro", "nikto", "ninja", "nutch", "octopus", "pagegrabber", "planetwork", "postrank", "proximic", "purebot",
|
||||
"pycurl", "python", "queryn", "queryseeker", "radian6", "radiation", "realdownload", "rogerbot", "scooter", "seekerspider",
|
||||
"semalt", "siclab", "sindice", "sistrix", "sitebot", "siteexplorer", "sitesnagger", "skygrid", "smartdownload", "snoopy",
|
||||
"sosospider", "spankbot", "spbot", "sqlmap", "stackrambler", "stripper", "sucker", "surftbot", "sux0r", "suzukacz",
|
||||
"suzuran", "takeout", "teleport", "telesoft", "true_robots", "turingos", "turnit", "vampire", "vikspider", "voideye",
|
||||
"webleacher", "webreaper", "webstripper", "webvac", "webviewer", "webwhacker", "winhttp", "wwwoffle", "woxbot",
|
||||
"xaldon", "xxxyy", "yamanalab", "yioopbot", "youda", "zeus", "zmeu", "zune", "zyborg",
|
||||
}
|
||||
|
||||
// botDetectionMiddleware checks if the User-Agent is a known bot
|
||||
func BotDetectionMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
if userAgent == "" {
|
||||
http.Error(w, "Access denied: Bots are not allowed.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
for _, botUserAgent := range botUserAgents {
|
||||
if userAgent == botUserAgent {
|
||||
http.Error(w, "Access denied: Bots are not allowed.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no bot user agent is detected, pass the request to the next handler
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
|
@ -15,8 +15,9 @@ import (
|
|||
func tokenMiddleware(enabled bool) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Header.Set("x-cosmos-user", "")
|
||||
r.Header.Set("x-cosmos-role", "")
|
||||
r.Header.Del("x-cosmos-user")
|
||||
r.Header.Del("x-cosmos-role")
|
||||
r.Header.Del("x-cosmos-mfa")
|
||||
|
||||
u, err := user.RefreshUserToken(w, r)
|
||||
|
||||
|
@ -26,6 +27,7 @@ func tokenMiddleware(enabled bool) func(next http.Handler) http.Handler {
|
|||
|
||||
r.Header.Set("x-cosmos-user", u.Nickname)
|
||||
r.Header.Set("x-cosmos-role", strconv.Itoa((int)(u.Role)))
|
||||
r.Header.Set("x-cosmos-mfa", strconv.Itoa((int)(u.MFAState)))
|
||||
|
||||
ogcookies := r.Header.Get("Cookie")
|
||||
cookieRemoveRegex := regexp.MustCompile(`jwttoken=[^;]*;`)
|
||||
|
@ -106,7 +108,17 @@ func RouterGen(route utils.ProxyRouteConfig, router *mux.Router, destination htt
|
|||
destination = utils.BandwithLimiterMiddleware(route.MaxBandwith)(destination)
|
||||
}
|
||||
|
||||
origin.Handler(tokenMiddleware(route.AuthEnabled)(utils.CORSHeader(originCORS)((destination))))
|
||||
if route.BlockCommonBots {
|
||||
destination = BotDetectionMiddleware(destination)
|
||||
}
|
||||
|
||||
if route.BlockAPIAbuse {
|
||||
destination = utils.BlockPostWithoutReferer(destination)
|
||||
}
|
||||
|
||||
destination = tokenMiddleware(route.AuthEnabled)(utils.CORSHeader(originCORS)((destination)))
|
||||
|
||||
origin.Handler(destination)
|
||||
|
||||
utils.Log("Added route: [" + (string)(route.Mode) + "] " + route.Host + route.PathPrefix + " to " + route.Target + "")
|
||||
|
||||
|
|
|
@ -38,10 +38,29 @@ type userUsedBudget struct {
|
|||
Time float64
|
||||
Requests int
|
||||
Bytes int64
|
||||
Simultaneous int
|
||||
}
|
||||
|
||||
var shield smartShieldState
|
||||
|
||||
func (shield *smartShieldState) GetServerNbReq() int {
|
||||
shield.Lock()
|
||||
defer shield.Unlock()
|
||||
nbRequests := 0
|
||||
|
||||
for i := len(shield.requests) - 1; i >= 0; i-- {
|
||||
request := shield.requests[i]
|
||||
if(request.IsOld()) {
|
||||
return nbRequests
|
||||
}
|
||||
if(!request.IsOver()) {
|
||||
nbRequests++
|
||||
}
|
||||
}
|
||||
|
||||
return nbRequests
|
||||
}
|
||||
|
||||
func (shield *smartShieldState) GetUserUsedBudgets(ClientID string) userUsedBudget {
|
||||
shield.Lock()
|
||||
defer shield.Unlock()
|
||||
|
@ -51,6 +70,7 @@ func (shield *smartShieldState) GetUserUsedBudgets(ClientID string) userUsedBudg
|
|||
Time: 0,
|
||||
Requests: 0,
|
||||
Bytes: 0,
|
||||
Simultaneous: 0,
|
||||
}
|
||||
|
||||
// Check for recent requests
|
||||
|
@ -64,6 +84,7 @@ func (shield *smartShieldState) GetUserUsedBudgets(ClientID string) userUsedBudg
|
|||
userConsumed.Time += request.TimeEnded.Sub(request.TimeStarted).Seconds()
|
||||
} else {
|
||||
userConsumed.Time += time.Now().Sub(request.TimeStarted).Seconds()
|
||||
userConsumed.Simultaneous++
|
||||
}
|
||||
userConsumed.Requests += request.RequestCost
|
||||
userConsumed.Bytes += request.Bytes
|
||||
|
@ -125,7 +146,8 @@ func (shield *smartShieldState) isAllowedToReqest(policy utils.SmartShieldPolicy
|
|||
// Check for new strikes
|
||||
if (userConsumed.Time > (policy.PerUserTimeBudget * float64(policy.PolicyStrictness))) ||
|
||||
(userConsumed.Requests > (policy.PerUserRequestLimit * policy.PolicyStrictness)) ||
|
||||
(userConsumed.Bytes > (policy.PerUserByteLimit * int64(policy.PolicyStrictness))) {
|
||||
(userConsumed.Bytes > (policy.PerUserByteLimit * int64(policy.PolicyStrictness))) ||
|
||||
(userConsumed.Simultaneous > (policy.PerUserSimultaneous * policy.PolicyStrictness)) {
|
||||
shield.bans = append(shield.bans, &userBan{
|
||||
ClientID: ClientID,
|
||||
banType: STRIKE,
|
||||
|
@ -155,7 +177,16 @@ func (shield *smartShieldState) computeThrottle(policy utils.SmartShieldPolicy,
|
|||
overByte := policy.PerUserByteLimit - userConsumed.Bytes
|
||||
overByteRatio := float64(overByte) / float64(policy.PerUserByteLimit)
|
||||
if overByte < 0 {
|
||||
newThrottle := int(float64(150) * -overByteRatio)
|
||||
newThrottle := int(float64(40) * -overByteRatio)
|
||||
if newThrottle > throttle {
|
||||
throttle = newThrottle
|
||||
}
|
||||
}
|
||||
|
||||
overSim := policy.PerUserSimultaneous - userConsumed.Simultaneous
|
||||
overSimRatio := float64(overSim) / float64(policy.PerUserSimultaneous)
|
||||
if overSim < 0 {
|
||||
newThrottle := int(float64(20) * -overSimRatio)
|
||||
if newThrottle > throttle {
|
||||
throttle = newThrottle
|
||||
}
|
||||
|
@ -185,6 +216,11 @@ func GetClientID(r *http.Request) string {
|
|||
return ip
|
||||
}
|
||||
|
||||
func isPrivileged(req *http.Request, policy utils.SmartShieldPolicy) bool {
|
||||
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
|
||||
return role >= policy.PrivilegedGroups
|
||||
}
|
||||
|
||||
func SmartShieldMiddleware(policy utils.SmartShieldPolicy) func(http.Handler) http.Handler {
|
||||
if policy.Enabled == false {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
@ -198,25 +234,46 @@ func SmartShieldMiddleware(policy utils.SmartShieldPolicy) func(http.Handler) ht
|
|||
policy.PerUserRequestLimit = 6000 // 100 requests per minute
|
||||
}
|
||||
if(policy.PerUserByteLimit == 0) {
|
||||
policy.PerUserByteLimit = 3 * 60 * 1024 * 1024 * 1024 // 180GB
|
||||
policy.PerUserByteLimit = 150 * 1024 * 1024 * 1024 // 150GB
|
||||
}
|
||||
if(policy.PolicyStrictness == 0) {
|
||||
policy.PolicyStrictness = 2 // NORMAL
|
||||
}
|
||||
if(policy.PerUserSimultaneous == 0) {
|
||||
policy.PerUserSimultaneous = 2
|
||||
}
|
||||
if(policy.MaxGlobalSimultaneous == 0) {
|
||||
policy.MaxGlobalSimultaneous = 50
|
||||
}
|
||||
if(policy.PrivilegedGroups == 0) {
|
||||
policy.PrivilegedGroups = utils.ADMIN
|
||||
}
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
utils.Log("SmartShield: Request received")
|
||||
currentGlobalRequests := shield.GetServerNbReq() + 1
|
||||
utils.Debug(fmt.Sprintf("SmartShield: Current global requests: %d", currentGlobalRequests))
|
||||
|
||||
if currentGlobalRequests > policy.MaxGlobalSimultaneous && !isPrivileged(r, policy) {
|
||||
utils.Log("SmartShield: Too many users on the server")
|
||||
http.Error(w, "Too many requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := GetClientID(r)
|
||||
userConsumed := shield.GetUserUsedBudgets(clientID)
|
||||
|
||||
if !shield.isAllowedToReqest(policy, userConsumed) {
|
||||
utils.Log("SmartShield: User is banned")
|
||||
if !isPrivileged(r, policy) && !shield.isAllowedToReqest(policy, userConsumed) {
|
||||
utils.Log("SmartShield: User is blocked due to abuse")
|
||||
http.Error(w, "Too many requests", http.StatusTooManyRequests)
|
||||
return
|
||||
} else {
|
||||
utils.Debug("SmartShield: Creating request")
|
||||
throttle := shield.computeThrottle(policy, userConsumed)
|
||||
throttle := 0
|
||||
if(!isPrivileged(r, policy)) {
|
||||
throttle = shield.computeThrottle(policy, userConsumed)
|
||||
}
|
||||
wrapper := &SmartResponseWriterWrapper {
|
||||
ResponseWriter: w,
|
||||
ThrottleNext: throttle,
|
||||
|
@ -226,6 +283,7 @@ func SmartShieldMiddleware(policy utils.SmartShieldPolicy) func(http.Handler) ht
|
|||
Method: r.Method,
|
||||
shield: shield,
|
||||
policy: policy,
|
||||
isPrivileged: isPrivileged(r, policy),
|
||||
}
|
||||
|
||||
// add rate limite headers
|
||||
|
@ -234,19 +292,26 @@ func SmartShieldMiddleware(policy utils.SmartShieldPolicy) func(http.Handler) ht
|
|||
w.Header().Set("X-RateLimit-Limit", strconv.FormatInt(int64(policy.PerUserRequestLimit), 10))
|
||||
w.Header().Set("X-RateLimit-Reset", In20Minutes)
|
||||
|
||||
utils.Debug("SmartShield: Adding request")
|
||||
shield.Lock()
|
||||
shield.requests = append(shield.requests, wrapper)
|
||||
shield.Unlock()
|
||||
|
||||
utils.Debug("SmartShield: Processing request")
|
||||
next.ServeHTTP(wrapper, r)
|
||||
ctx := r.Context()
|
||||
done := make(chan struct{})
|
||||
|
||||
go (func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-done:
|
||||
}
|
||||
shield.Lock()
|
||||
wrapper.TimeEnded = time.Now()
|
||||
wrapper.isOver = true
|
||||
shield.Unlock()
|
||||
utils.Debug("SmartShield: Request finished")
|
||||
})()
|
||||
|
||||
next.ServeHTTP(wrapper, r)
|
||||
close(done)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
88
src/user/2fa_check.go
Normal file
|
@ -0,0 +1,88 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/azukaar/cosmos-server/src/utils"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
type User2FACheckRequest struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
func Check2FA(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.LoggedInWeakOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
nickname := req.Header.Get("x-cosmos-user")
|
||||
|
||||
var request User2FACheckRequest
|
||||
errD := json.NewDecoder(req.Body).Decode(&request)
|
||||
if errD != nil {
|
||||
utils.Error("2FA Error: Invalid User Request", errD)
|
||||
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA001")
|
||||
return
|
||||
}
|
||||
|
||||
c, errCo := utils.GetCollection(utils.GetRootAppId(), "users")
|
||||
if errCo != nil {
|
||||
utils.Error("Database Connect", errCo)
|
||||
utils.HTTPError(w, "Database", http.StatusInternalServerError, "DB001")
|
||||
return
|
||||
}
|
||||
|
||||
userInBase := utils.User{}
|
||||
|
||||
err := c.FindOne(nil, map[string]interface{}{
|
||||
"Nickname": nickname,
|
||||
}).Decode(&userInBase)
|
||||
|
||||
if err != nil {
|
||||
utils.Error("UserGet: Error while getting user", err)
|
||||
utils.HTTPError(w, "User Get Error", http.StatusInternalServerError, "2FA002")
|
||||
return
|
||||
}
|
||||
|
||||
if(userInBase.MFAKey == "") {
|
||||
utils.Error("2FA: User " + nickname + " has no key", nil)
|
||||
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA003")
|
||||
return
|
||||
}
|
||||
|
||||
valid := totp.Validate(request.Token, userInBase.MFAKey)
|
||||
|
||||
if valid {
|
||||
utils.Log("2FA: User " + nickname + " has valid token")
|
||||
|
||||
if(!userInBase.Was2FAVerified) {
|
||||
toSet := map[string]interface{}{
|
||||
"Was2FAVerified": true,
|
||||
}
|
||||
|
||||
_, err = c.UpdateOne(nil, map[string]interface{}{
|
||||
"Nickname": nickname,
|
||||
}, map[string]interface{}{
|
||||
"$set": toSet,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
utils.Error("2FA: Cannot update user", err)
|
||||
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA004")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
SendUserToken(w, userInBase, true)
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
})
|
||||
} else {
|
||||
utils.Error("2FA: User " + nickname + " has invalid token", nil)
|
||||
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA005")
|
||||
return
|
||||
}
|
||||
}
|
84
src/user/2fa_new.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
"math/rand"
|
||||
|
||||
"github.com/azukaar/cosmos-server/src/utils"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
func New2FA(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.LoggedInWeakOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Duration(rand.Float64()*2)*time.Second)
|
||||
|
||||
nickname := req.Header.Get("x-cosmos-user")
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "Cosmos " + utils.GetMainConfig().HTTPConfig.Hostname,
|
||||
AccountName: nickname,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
utils.Error("2FA: Cannot generate key", err)
|
||||
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA001")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Log("2FA: New key generated for " + nickname)
|
||||
|
||||
toSet := map[string]interface{}{
|
||||
"MFAKey": key.Secret(),
|
||||
"Was2FAVerified": false,
|
||||
}
|
||||
|
||||
c, errCo := utils.GetCollection(utils.GetRootAppId(), "users")
|
||||
if errCo != nil {
|
||||
utils.Error("Database Connect", errCo)
|
||||
utils.HTTPError(w, "Database", http.StatusInternalServerError, "DB001")
|
||||
return
|
||||
}
|
||||
|
||||
userInBase := utils.User{}
|
||||
|
||||
err = c.FindOne(nil, map[string]interface{}{
|
||||
"Nickname": nickname,
|
||||
}).Decode(&userInBase)
|
||||
|
||||
if err != nil {
|
||||
utils.Error("UserGet: Error while getting user", err)
|
||||
utils.HTTPError(w, "User Get Error", http.StatusInternalServerError, "UD001")
|
||||
return
|
||||
}
|
||||
|
||||
if(userInBase.MFAKey != "" && userInBase.Was2FAVerified) {
|
||||
if utils.LoggedInOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err = c.UpdateOne(nil, map[string]interface{}{
|
||||
"Nickname": nickname,
|
||||
}, map[string]interface{}{
|
||||
"$set": toSet,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
utils.Error("2FA: Cannot update user", err)
|
||||
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA002")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Log("2FA: User " + nickname + " updated")
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
"data": map[string]interface{}{
|
||||
"key": key.URL(),
|
||||
},
|
||||
})
|
||||
}
|
69
src/user/2fa_reset.go
Normal file
|
@ -0,0 +1,69 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/azukaar/cosmos-server/src/utils"
|
||||
)
|
||||
|
||||
type User2FAResetRequest struct {
|
||||
Nickname string
|
||||
}
|
||||
|
||||
func Delete2FA(w http.ResponseWriter, req *http.Request) {
|
||||
if utils.AdminOnly(w, req) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var request User2FAResetRequest
|
||||
errD := json.NewDecoder(req.Body).Decode(&request)
|
||||
if errD != nil {
|
||||
utils.Error("2FA Error: Invalid User Request", errD)
|
||||
utils.HTTPError(w, "2FA Error", http.StatusInternalServerError, "2FA001")
|
||||
return
|
||||
}
|
||||
|
||||
nickname := request.Nickname
|
||||
|
||||
c, errCo := utils.GetCollection(utils.GetRootAppId(), "users")
|
||||
if errCo != nil {
|
||||
utils.Error("Database Connect", errCo)
|
||||
utils.HTTPError(w, "Database", http.StatusInternalServerError, "DB001")
|
||||
return
|
||||
}
|
||||
|
||||
userInBase := utils.User{}
|
||||
|
||||
err := c.FindOne(nil, map[string]interface{}{
|
||||
"Nickname": nickname,
|
||||
}).Decode(&userInBase)
|
||||
|
||||
if err != nil {
|
||||
utils.Error("UserGet: Error while getting user", err)
|
||||
utils.HTTPError(w, "User Get Error", http.StatusInternalServerError, "2FA002")
|
||||
return
|
||||
}
|
||||
|
||||
toSet := map[string]interface{}{
|
||||
"Was2FAVerified": false,
|
||||
"MFAKey": "",
|
||||
"PasswordCycle": userInBase.PasswordCycle + 1,
|
||||
}
|
||||
|
||||
_, err = c.UpdateOne(nil, map[string]interface{}{
|
||||
"Nickname": nickname,
|
||||
}, map[string]interface{}{
|
||||
"$set": toSet,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
utils.Error("UserGet: Error while getting user", err)
|
||||
utils.HTTPError(w, "User Get Error", http.StatusInternalServerError, "2FA002")
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
})
|
||||
}
|
|
@ -8,6 +8,7 @@ import (
|
|||
"golang.org/x/crypto/bcrypt"
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/azukaar/cosmos-server/src/utils"
|
||||
)
|
||||
|
||||
|
@ -69,7 +70,7 @@ func UserLogin(w http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
SendUserToken(w, user)
|
||||
SendUserToken(w, user, false)
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "OK",
|
||||
|
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
type RegisterRequestJSON struct {
|
||||
Nickname string `validate:"required,min=3,max=32,alphanum"`
|
||||
Password string `validate:"required,min=8,max=128,containsany=!@#$%^&*()_+,containsany=ABCDEFGHIJKLMNOPQRSTUVWXYZ,containsany=abcdefghijklmnopqrstuvwxyz,containsany=0123456789"`
|
||||
Password string `validate:"required,min=9,max=128,containsany=!@#$%^&*()_+,containsany=ABCDEFGHIJKLMNOPQRSTUVWXYZ,containsany=abcdefghijklmnopqrstuvwxyz,containsany=0123456789"`
|
||||
RegisterKey string `validate:"required,min=1,max=512,alphanum"`
|
||||
}
|
||||
|
||||
|
|
|
@ -10,16 +10,18 @@ import (
|
|||
"encoding/json"
|
||||
)
|
||||
|
||||
func quickLoggout(w http.ResponseWriter, req *http.Request, err error) (utils.User, error) {
|
||||
utils.Error("UserToken: Token likely falsified", err)
|
||||
logOutUser(w)
|
||||
redirectToReLogin(w, req)
|
||||
return utils.User{}, errors.New("Token likely falsified")
|
||||
}
|
||||
|
||||
func RefreshUserToken(w http.ResponseWriter, req *http.Request) (utils.User, error) {
|
||||
// if(utils.DB != nil) {
|
||||
// return utils.User{
|
||||
// Nickname: "noname",
|
||||
// Role: utils.ADMIN,
|
||||
// }, nil
|
||||
// }
|
||||
config := utils.GetMainConfig()
|
||||
|
||||
// if new install
|
||||
if utils.GetMainConfig().NewInstall {
|
||||
if config.NewInstall {
|
||||
// check route
|
||||
if req.URL.Path != "/cosmos/api/status" && req.URL.Path != "/cosmos/api/newInstall" {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
|
@ -56,10 +58,9 @@ func RefreshUserToken(w http.ResponseWriter, req *http.Request) (utils.User, err
|
|||
errT := jwt.SigningMethodEdDSA.Verify(strings.Join(parts[0:2], "."), parts[2], ed25519Key)
|
||||
|
||||
if errT != nil {
|
||||
utils.Error("UserToken: Token likely falsified", errT)
|
||||
logOutUser(w)
|
||||
redirectToReLogin(w, req)
|
||||
return utils.User{}, errors.New("Token likely falsified")
|
||||
if _, e := quickLoggout(w, req, errT); e != nil {
|
||||
return utils.User{}, errT
|
||||
}
|
||||
}
|
||||
|
||||
type claimsType struct {
|
||||
|
@ -79,8 +80,32 @@ func RefreshUserToken(w http.ResponseWriter, req *http.Request) (utils.User, err
|
|||
return utils.User{}, errors.New("Token not valid")
|
||||
}
|
||||
|
||||
nickname := claims["nickname"].(string)
|
||||
passwordCycle := int(claims["passwordCycle"].(float64))
|
||||
var (
|
||||
nickname string
|
||||
passwordCycle int
|
||||
mfaDone bool
|
||||
ok bool
|
||||
)
|
||||
|
||||
if nickname, ok = claims["nickname"].(string); !ok {
|
||||
if _, e := quickLoggout(w, req, nil); e != nil {
|
||||
return utils.User{}, e
|
||||
}
|
||||
}
|
||||
|
||||
if passwordCycleFloat, ok := claims["passwordCycle"].(float64); ok {
|
||||
passwordCycle = int(passwordCycleFloat)
|
||||
} else {
|
||||
if _, e := quickLoggout(w, req, nil); e != nil {
|
||||
return utils.User{}, e
|
||||
}
|
||||
}
|
||||
|
||||
if mfaDone, ok = claims["mfaDone"].(bool); !ok {
|
||||
if _, e := quickLoggout(w, req, nil); e != nil {
|
||||
return utils.User{}, e
|
||||
}
|
||||
}
|
||||
|
||||
userInBase := utils.User{}
|
||||
|
||||
|
@ -109,6 +134,23 @@ func RefreshUserToken(w http.ResponseWriter, req *http.Request) (utils.User, err
|
|||
return utils.User{}, errors.New("Password cycle changed, token is too old")
|
||||
}
|
||||
|
||||
requestURL := req.URL.Path
|
||||
isSettingMFA := strings.HasPrefix(requestURL, "/ui/loginmfa") || strings.HasPrefix(requestURL, "/ui/newmfa") || strings.HasPrefix(requestURL, "/api/mfa")
|
||||
|
||||
userInBase.MFAState = 0
|
||||
|
||||
if !isSettingMFA && (userInBase.MFAKey != "" && userInBase.Was2FAVerified && !mfaDone) {
|
||||
utils.Warn("UserToken: MFA required")
|
||||
userInBase.MFAState = 1
|
||||
} else if !isSettingMFA && (config.RequireMFA && !mfaDone) {
|
||||
utils.Warn("UserToken: MFA not set")
|
||||
userInBase.MFAState = 2
|
||||
}
|
||||
|
||||
if time.Now().Unix() - int64(claims["iat"].(float64)) > 3600 {
|
||||
SendUserToken(w, userInBase, mfaDone)
|
||||
}
|
||||
|
||||
return userInBase, nil
|
||||
}
|
||||
|
||||
|
@ -140,7 +182,15 @@ func redirectToReLogin(w http.ResponseWriter, req *http.Request) {
|
|||
http.Redirect(w, req, "/ui/login?invalid=1&redirect=" + req.URL.Path + "&" + req.URL.RawQuery, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func SendUserToken(w http.ResponseWriter, user utils.User) {
|
||||
func redirectToLoginMFA(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, "/ui/loginmfa?invalid=1&redirect=" + req.URL.Path + "&" + req.URL.RawQuery, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func redirectToNewMFA(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, "/ui/newmfa?invalid=1&redirect=" + req.URL.Path + "&" + req.URL.RawQuery, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func SendUserToken(w http.ResponseWriter, user utils.User, mfaDone bool) {
|
||||
expiration := time.Now().Add(3 * 24 * time.Hour)
|
||||
|
||||
token := jwt.New(jwt.SigningMethodEdDSA)
|
||||
|
@ -151,6 +201,7 @@ func SendUserToken(w http.ResponseWriter, user utils.User) {
|
|||
claims["passwordCycle"] = user.PasswordCycle
|
||||
claims["iat"] = time.Now().Unix()
|
||||
claims["nbf"] = time.Now().Unix()
|
||||
claims["mfaDone"] = mfaDone
|
||||
|
||||
key, err5 := jwt.ParseEdPrivateKeyFromPEM([]byte(utils.GetPrivateAuthKey()))
|
||||
|
||||
|
|
|
@ -30,3 +30,17 @@ func UsersRoute(w http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
}
|
||||
|
||||
func API2FA(w http.ResponseWriter, req *http.Request) {
|
||||
if(req.Method == "POST") {
|
||||
Check2FA(w, req)
|
||||
} else if (req.Method == "GET") {
|
||||
New2FA(w, req)
|
||||
} else if (req.Method == "DELETE") {
|
||||
Delete2FA(w, req)
|
||||
} else {
|
||||
utils.Error("API2FARoute: Method not allowed" + req.Method, nil)
|
||||
utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
|
||||
return
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@ import (
|
|||
"encoding/asn1"
|
||||
)
|
||||
|
||||
func GenerateRSAWebCertificates() (string, string) {
|
||||
func GenerateRSAWebCertificates(domains []string) (string, string) {
|
||||
// generate self signed certificate
|
||||
|
||||
Log("Generating RSA Web Certificates for " + GetMainConfig().HTTPConfig.Hostname)
|
||||
|
@ -57,7 +57,7 @@ func GenerateRSAWebCertificates() (string, string) {
|
|||
|
||||
PermittedDNSDomainsCritical: false,
|
||||
|
||||
PermittedDNSDomains: []string{GetMainConfig().HTTPConfig.Hostname},
|
||||
PermittedDNSDomains: domains,
|
||||
|
||||
// PermittedIPRanges: ,
|
||||
|
||||
|
|
128
src/utils/loggedIn.go
Normal file
|
@ -0,0 +1,128 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
|
||||
func LoggedInOnlyWithRedirect(w http.ResponseWriter, req *http.Request) error {
|
||||
userNickname := req.Header.Get("x-cosmos-user")
|
||||
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
|
||||
mfa, _ := strconv.Atoi(req.Header.Get("x-cosmos-mfa"))
|
||||
isUserLoggedIn := role > 0
|
||||
|
||||
if !isUserLoggedIn || userNickname == "" {
|
||||
Error("LoggedInOnlyWithRedirect: User is not logged in", nil)
|
||||
http.Redirect(w, req, "/ui/login?notlogged=1&redirect="+req.URL.Path, http.StatusFound)
|
||||
}
|
||||
|
||||
if(mfa == 1) {
|
||||
http.Redirect(w, req, "/ui/loginmfa?invalid=1&redirect=" + req.URL.Path + "&" + req.URL.RawQuery, http.StatusTemporaryRedirect)
|
||||
return errors.New("User requires MFA")
|
||||
} else if(mfa == 2) {
|
||||
http.Redirect(w, req, "/ui/newmfa?invalid=1&redirect=" + req.URL.Path + "&" + req.URL.RawQuery, http.StatusTemporaryRedirect)
|
||||
return errors.New("User requires MFA Setup")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoggedInWeakOnly(w http.ResponseWriter, req *http.Request) error {
|
||||
userNickname := req.Header.Get("x-cosmos-user")
|
||||
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
|
||||
isUserLoggedIn := role > 0
|
||||
|
||||
if !isUserLoggedIn || userNickname == "" {
|
||||
Error("LoggedInOnly: User is not logged in", nil)
|
||||
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
|
||||
return errors.New("User not logged in")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoggedInOnly(w http.ResponseWriter, req *http.Request) error {
|
||||
userNickname := req.Header.Get("x-cosmos-user")
|
||||
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
|
||||
mfa, _ := strconv.Atoi(req.Header.Get("x-cosmos-mfa"))
|
||||
isUserLoggedIn := role > 0
|
||||
|
||||
if !isUserLoggedIn || userNickname == "" {
|
||||
Error("LoggedInOnly: User is not logged in", nil)
|
||||
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
|
||||
return errors.New("User not logged in")
|
||||
}
|
||||
|
||||
if(mfa == 1) {
|
||||
HTTPError(w, "User not logged in (MFA)", http.StatusUnauthorized, "HTTP006")
|
||||
return errors.New("User requires MFA")
|
||||
} else if(mfa == 2) {
|
||||
HTTPError(w, "User requires MFA Setup", http.StatusUnauthorized, "HTTP007")
|
||||
return errors.New("User requires MFA Setup")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func AdminOnly(w http.ResponseWriter, req *http.Request) error {
|
||||
userNickname := req.Header.Get("x-cosmos-user")
|
||||
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
|
||||
mfa, _ := strconv.Atoi(req.Header.Get("x-cosmos-mfa"))
|
||||
isUserLoggedIn := role > 0
|
||||
isUserAdmin := role > 1
|
||||
|
||||
if !isUserLoggedIn || userNickname == "" {
|
||||
Error("AdminOnly: User is not logged in", nil)
|
||||
//http.Redirect(w, req, "/login?notlogged=1&redirect=" + req.URL.Path, http.StatusFound)
|
||||
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
|
||||
return errors.New("User not logged in")
|
||||
}
|
||||
|
||||
if isUserLoggedIn && !isUserAdmin {
|
||||
Error("AdminOnly: User is not admin", nil)
|
||||
HTTPError(w, "User unauthorized", http.StatusUnauthorized, "HTTP005")
|
||||
return errors.New("User not Admin")
|
||||
}
|
||||
|
||||
if(mfa == 1) {
|
||||
HTTPError(w, "User not logged in (MFA)", http.StatusUnauthorized, "HTTP006")
|
||||
return errors.New("User requires MFA")
|
||||
} else if(mfa == 2) {
|
||||
HTTPError(w, "User requires MFA Setup", http.StatusUnauthorized, "HTTP007")
|
||||
return errors.New("User requires MFA Setup")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func AdminOrItselfOnly(w http.ResponseWriter, req *http.Request, nickname string) error {
|
||||
userNickname := req.Header.Get("x-cosmos-user")
|
||||
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
|
||||
mfa, _ := strconv.Atoi(req.Header.Get("x-cosmos-mfa"))
|
||||
isUserLoggedIn := role > 0
|
||||
isUserAdmin := role > 1
|
||||
|
||||
if !isUserLoggedIn || userNickname == "" {
|
||||
Error("AdminOrItselfOnly: User is not logged in", nil)
|
||||
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
|
||||
return errors.New("User not logged in")
|
||||
}
|
||||
|
||||
if nickname != userNickname && !isUserAdmin {
|
||||
Error("AdminOrItselfOnly: User is not admin", nil)
|
||||
HTTPError(w, "User unauthorized", http.StatusUnauthorized, "HTTP005")
|
||||
return errors.New("User not Admin")
|
||||
}
|
||||
|
||||
if(mfa == 1) {
|
||||
HTTPError(w, "User not logged in (MFA)", http.StatusUnauthorized, "HTTP006")
|
||||
return errors.New("User requires MFA")
|
||||
} else if(mfa == 2) {
|
||||
HTTPError(w, "User requires MFA Setup", http.StatusUnauthorized, "HTTP007")
|
||||
return errors.New("User requires MFA Setup")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -4,7 +4,10 @@ import (
|
|||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
"net"
|
||||
|
||||
"github.com/mxk/go-flowrate/flowrate"
|
||||
"github.com/oschwald/geoip2-golang"
|
||||
)
|
||||
|
||||
// https://github.com/go-chi/chi/blob/master/middleware/timeout.go
|
||||
|
@ -95,3 +98,72 @@ func AcceptHeader(accept string) func(next http.Handler) http.Handler {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetIPLocation returns the ISO country code for a given IP address.
|
||||
func GetIPLocation(ip string) (string, error) {
|
||||
geoDB, err := geoip2.Open("GeoLite2-Country.mmdb")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer geoDB.Close()
|
||||
|
||||
parsedIP := net.ParseIP(ip)
|
||||
record, err := geoDB.Country(parsedIP)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return record.Country.IsoCode, nil
|
||||
}
|
||||
|
||||
// BlockByCountryMiddleware returns a middleware function that blocks requests from specified countries.
|
||||
func BlockByCountryMiddleware(blockedCountries []string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
countryCode, err := GetIPLocation(ip)
|
||||
|
||||
if err == nil {
|
||||
if countryCode == "" {
|
||||
Debug("Country code is empty")
|
||||
} else {
|
||||
Debug("Country code: " + countryCode)
|
||||
}
|
||||
|
||||
config := GetMainConfig()
|
||||
|
||||
for _, blockedCountry := range blockedCountries {
|
||||
if config.ServerCountry != countryCode && countryCode == blockedCountry {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Warn("Missing geolocation information to block IPs")
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// blockPostWithoutReferer blocks POST requests without a Referer header
|
||||
func BlockPostWithoutReferer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH" || r.Method == "DELETE" {
|
||||
referer := r.Header.Get("Referer")
|
||||
if referer == "" {
|
||||
http.Error(w, "Bad Request: Invalid request.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If it's not a POST request or the POST request has a Referer header, pass the request to the next handler
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
|
@ -68,6 +68,9 @@ type User struct {
|
|||
LastPasswordChangedAt time.Time `json:"lastPasswordChangedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastLogin time.Time `json:"lastLogin"`
|
||||
MFAKey string `json:"-"`
|
||||
Was2FAVerified bool `json:"-"`
|
||||
MFAState int `json:"-"` // 0 = done, 1 = needed, 2 = not set
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
|
@ -77,15 +80,20 @@ type Config struct {
|
|||
NewInstall bool `validate:"boolean"`
|
||||
HTTPConfig HTTPConfig `validate:"required,dive,required"`
|
||||
DockerConfig DockerConfig
|
||||
BlockedCountries []string
|
||||
ServerCountry string
|
||||
RequireMFA bool
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
TLSCert string `validate:"omitempty,contains=\n`
|
||||
TLSKey string
|
||||
TLSKeyHostsCached []string
|
||||
AuthPrivateKey string
|
||||
AuthPublicKey string
|
||||
GenerateMissingAuthCert bool
|
||||
HTTPSCertificateMode string
|
||||
DNSChallengeProvider string
|
||||
HTTPPort string `validate:"required,containsany=0123456789,min=1,max=6"`
|
||||
HTTPSPort string `validate:"required,containsany=0123456789,min=1,max=6"`
|
||||
ProxyConfig ProxyConfig
|
||||
|
@ -104,6 +112,9 @@ type SmartShieldPolicy struct {
|
|||
PerUserTimeBudget float64
|
||||
PerUserRequestLimit int
|
||||
PerUserByteLimit int64
|
||||
PerUserSimultaneous int
|
||||
MaxGlobalSimultaneous int
|
||||
PrivilegedGroups int
|
||||
}
|
||||
|
||||
type DockerConfig struct {
|
||||
|
@ -130,4 +141,6 @@ type ProxyRouteConfig struct {
|
|||
Target string `validate:"required"`
|
||||
SmartShield SmartShieldPolicy
|
||||
Mode ProxyMode
|
||||
BlockCommonBots bool
|
||||
BlockAPIAbuse bool
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ package utils
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"net/http"
|
||||
|
@ -22,6 +21,42 @@ var NeedsRestart = false
|
|||
var DefaultConfig = Config{
|
||||
LoggingLevel: "INFO",
|
||||
NewInstall: true,
|
||||
// By default we block all countries that have a high amount of attacks
|
||||
// Note that Cosmos wont block the country of origin of the server even if it is in this list
|
||||
BlockedCountries: []string{
|
||||
// china
|
||||
"CN",
|
||||
// Russia
|
||||
"RU",
|
||||
// turkey
|
||||
"TR",
|
||||
// Brazil
|
||||
"BR",
|
||||
// Bangladesh
|
||||
"BD",
|
||||
// India
|
||||
"IN",
|
||||
// Nepal
|
||||
"NP",
|
||||
// Pakistan
|
||||
"PK",
|
||||
// Sri Lanka
|
||||
"LK",
|
||||
// Vietnam
|
||||
"VN",
|
||||
// Indonesia
|
||||
"ID",
|
||||
// Iran
|
||||
"IR",
|
||||
// Iraq
|
||||
"IQ",
|
||||
// Egypt
|
||||
"EG",
|
||||
// Afghanistan
|
||||
"AF",
|
||||
// Romania
|
||||
"RO",
|
||||
},
|
||||
HTTPConfig: HTTPConfig{
|
||||
HTTPSCertificateMode: "DISABLED",
|
||||
GenerateMissingAuthCert: true,
|
||||
|
@ -162,6 +197,9 @@ func LoadBaseMainConfig(config Config){
|
|||
if os.Getenv("COSMOS_MONGODB") != "" {
|
||||
MainConfig.MongoDB = os.Getenv("COSMOS_MONGODB")
|
||||
}
|
||||
if os.Getenv("COSMOS_SERVER_COUNTRY") != "" {
|
||||
MainConfig.ServerCountry = os.Getenv("COSMOS_SERVER_COUNTRY")
|
||||
}
|
||||
}
|
||||
|
||||
func GetMainConfig() Config {
|
||||
|
@ -240,77 +278,6 @@ func RestartServer() {
|
|||
os.Exit(0)
|
||||
}
|
||||
|
||||
func LoggedInOnlyWithRedirect(w http.ResponseWriter, req *http.Request) error {
|
||||
userNickname := req.Header.Get("x-cosmos-user")
|
||||
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
|
||||
isUserLoggedIn := role > 0
|
||||
|
||||
if !isUserLoggedIn || userNickname == "" {
|
||||
Error("LoggedInOnlyWithRedirect: User is not logged in", nil)
|
||||
http.Redirect(w, req, "/ui/login?notlogged=1&redirect="+req.URL.Path, http.StatusFound)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoggedInOnly(w http.ResponseWriter, req *http.Request) error {
|
||||
userNickname := req.Header.Get("x-cosmos-user")
|
||||
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
|
||||
isUserLoggedIn := role > 0
|
||||
|
||||
if !isUserLoggedIn || userNickname == "" {
|
||||
Error("LoggedInOnly: User is not logged in", nil)
|
||||
//http.Redirect(w, req, "/login?notlogged=1&redirect=" + req.URL.Path, http.StatusFound)
|
||||
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
|
||||
return errors.New("User not logged in")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func AdminOnly(w http.ResponseWriter, req *http.Request) error {
|
||||
userNickname := req.Header.Get("x-cosmos-user")
|
||||
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
|
||||
isUserLoggedIn := role > 0
|
||||
isUserAdmin := role > 1
|
||||
|
||||
if !isUserLoggedIn || userNickname == "" {
|
||||
Error("AdminOnly: User is not logged in", nil)
|
||||
//http.Redirect(w, req, "/login?notlogged=1&redirect=" + req.URL.Path, http.StatusFound)
|
||||
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
|
||||
return errors.New("User not logged in")
|
||||
}
|
||||
|
||||
if isUserLoggedIn && !isUserAdmin {
|
||||
Error("AdminOnly: User is not admin", nil)
|
||||
HTTPError(w, "User unauthorized", http.StatusUnauthorized, "HTTP005")
|
||||
return errors.New("User not Admin")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func AdminOrItselfOnly(w http.ResponseWriter, req *http.Request, nickname string) error {
|
||||
userNickname := req.Header.Get("x-cosmos-user")
|
||||
role, _ := strconv.Atoi(req.Header.Get("x-cosmos-role"))
|
||||
isUserLoggedIn := role > 0
|
||||
isUserAdmin := role > 1
|
||||
|
||||
if !isUserLoggedIn || userNickname == "" {
|
||||
Error("AdminOrItselfOnly: User is not logged in", nil)
|
||||
HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004")
|
||||
return errors.New("User not logged in")
|
||||
}
|
||||
|
||||
if nickname != userNickname && !isUserAdmin {
|
||||
Error("AdminOrItselfOnly: User is not admin", nil)
|
||||
HTTPError(w, "User unauthorized", http.StatusUnauthorized, "HTTP005")
|
||||
return errors.New("User not Admin")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetAllHostnames() []string {
|
||||
hostnames := []string{
|
||||
GetMainConfig().HTTPConfig.Hostname,
|
||||
|
@ -343,3 +310,32 @@ func GetAvailableRAM() uint64 {
|
|||
// Use total available memory as an approximation
|
||||
return vmStat.Available
|
||||
}
|
||||
|
||||
func StringArrayEquals(a []string, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, value := range a {
|
||||
if !StringArrayContains(b, value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for _, value := range b {
|
||||
if !StringArrayContains(a, value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func StringArrayContains(a []string, b string) bool {
|
||||
for _, value := range a {
|
||||
if value == b {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|