mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ node_modules
|
||||
npm-debug.log
|
||||
lerna-debug.log
|
||||
.nova
|
||||
*.code-workspace
|
||||
|
||||
@@ -3,13 +3,14 @@ import session from 'express-session';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import Joi from 'joi';
|
||||
import grant from 'grant';
|
||||
import getGrantConfig from '../utils/get-grant-config';
|
||||
import getEmailFromProfile from '../utils/get-email-from-profile';
|
||||
import { InvalidPayloadException } from '../exceptions/invalid-payload';
|
||||
import ms from 'ms';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import env from '../env';
|
||||
import { UsersService, AuthenticationService } from '../services';
|
||||
import grantConfig from '../grant';
|
||||
import { RouteNotFoundException } from '../exceptions';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -203,19 +204,41 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.get('/oauth', asyncHandler(async (req, res, next) => {
|
||||
const providers = env.OAUTH_PROVIDERS.split(',');
|
||||
res.locals.payload = { data: providers };
|
||||
return next();
|
||||
}));
|
||||
|
||||
router.use(
|
||||
'/sso',
|
||||
'/oauth',
|
||||
session({ secret: env.SECRET as string, saveUninitialized: false, resave: false })
|
||||
);
|
||||
|
||||
router.use(grant.express()(getGrantConfig()));
|
||||
router.get('/oauth/:provider', asyncHandler(async(req, res, next) => {
|
||||
const config = { ...grantConfig };
|
||||
delete config.defaults;
|
||||
|
||||
const availableProviders = Object.keys(config);
|
||||
|
||||
if (availableProviders.includes(req.params.provider) === false) {
|
||||
throw new RouteNotFoundException(`/auth/oauth/${req.params.provider}`);
|
||||
}
|
||||
|
||||
if (req.query?.redirect && req.session) {
|
||||
req.session.redirect = req.query.redirect;
|
||||
}
|
||||
|
||||
next();
|
||||
}));
|
||||
|
||||
router.use(grant.express()(grantConfig));
|
||||
|
||||
/**
|
||||
* @todo allow json / cookie mode in SSO
|
||||
*/
|
||||
router.get(
|
||||
'/sso/:provider/callback',
|
||||
'/oauth/:provider/callback',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const redirect = req.session?.redirect;
|
||||
|
||||
const accountability = {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
@@ -226,17 +249,29 @@ router.get(
|
||||
accountability: accountability,
|
||||
});
|
||||
|
||||
const email = getEmailFromProfile(req.params.provider, req.session!.grant.response.profile);
|
||||
const email = getEmailFromProfile(req.params.provider, req.session!.grant.response?.profile);
|
||||
|
||||
const { accessToken, refreshToken, expires } = await authenticationService.authenticate(
|
||||
email
|
||||
);
|
||||
req.session?.destroy(() => { });
|
||||
|
||||
res.locals.payload = {
|
||||
data: { access_token: accessToken, refresh_token: refreshToken, expires },
|
||||
};
|
||||
const { accessToken, refreshToken, expires } = await authenticationService.authenticate({ email });
|
||||
|
||||
return next();
|
||||
if (redirect) {
|
||||
res.cookie('directus_refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
|
||||
secure: env.REFRESH_TOKEN_COOKIE_SECURE === 'true' ? true : false,
|
||||
sameSite:
|
||||
(env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
|
||||
});
|
||||
|
||||
return res.redirect(redirect);
|
||||
} else {
|
||||
res.locals.payload = {
|
||||
data: { access_token: accessToken, refresh_token: refreshToken, expires },
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
39
api/src/grant.ts
Normal file
39
api/src/grant.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Grant is the oAuth library
|
||||
*/
|
||||
|
||||
import env from './env';
|
||||
|
||||
const enabledProviders = (env.OAUTH_PROVIDERS as string)
|
||||
.split(',')
|
||||
.map((provider) => provider.trim().toLowerCase());
|
||||
|
||||
const config: any = {
|
||||
defaults: {
|
||||
origin: env.PUBLIC_URL,
|
||||
transport: 'session',
|
||||
prefix: '/auth/oauth',
|
||||
response: ['tokens', 'profile'],
|
||||
},
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (key.startsWith('OAUTH') === false) continue;
|
||||
|
||||
const parts = key.split('_');
|
||||
const provider = parts[1].toLowerCase();
|
||||
|
||||
if (enabledProviders.includes(provider) === false) continue;
|
||||
|
||||
// OAUTH <PROVIDER> SETTING = VALUE
|
||||
parts.splice(0, 2);
|
||||
|
||||
const configKey = parts.join('_').toLowerCase();
|
||||
|
||||
config[provider] = {
|
||||
...(config[provider] || {}),
|
||||
[configKey]: value,
|
||||
};
|
||||
}
|
||||
|
||||
export default config;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { get } from 'lodash';
|
||||
import env from '../env';
|
||||
import { ServiceUnavailableException } from '../exceptions';
|
||||
|
||||
// The path in JSON to fetch the email address from the profile.
|
||||
// Note: a lot of services use `email` as the path. We fall back to that as default, so no need to
|
||||
@@ -17,10 +18,11 @@ const profileMap: Record<string, string> = {};
|
||||
export default function getEmailFromProfile(provider: string, profile: Record<string, any>) {
|
||||
const path =
|
||||
profileMap[provider] || env[`OAUTH_${provider.toUpperCase()}_PROFILE_EMAIL`] || 'email';
|
||||
|
||||
const email = get(profile, path);
|
||||
|
||||
if (!email) {
|
||||
throw new Error("Couldn't extract email address from SSO provider response");
|
||||
throw new ServiceUnavailableException("Couldn't extract email address from SSO provider response", { service: 'oauth', provider });
|
||||
}
|
||||
|
||||
return email;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import env from '../env';
|
||||
|
||||
/**
|
||||
* Reads the environment variables to construct the configuration object required by Grant
|
||||
*/
|
||||
export default function getGrantConfig() {
|
||||
const enabledProviders = (env.OAUTH_PROVIDERS as string)
|
||||
.split(',')
|
||||
.map((provider) => provider.trim());
|
||||
|
||||
const config: any = {
|
||||
defaults: {
|
||||
origin: env.PUBLIC_URL,
|
||||
transport: 'session',
|
||||
prefix: '/auth/sso',
|
||||
response: ['tokens', 'profile'],
|
||||
},
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (key.startsWith('OAUTH') === false) continue;
|
||||
|
||||
const parts = key.split('_');
|
||||
const provider = parts[1].toLowerCase();
|
||||
|
||||
if (enabledProviders.includes(provider) === false) continue;
|
||||
|
||||
// OAUTH <PROVIDER> SETTING = VALUE
|
||||
parts.splice(0, 2);
|
||||
|
||||
const configKey = parts.join('_').toLowerCase();
|
||||
|
||||
config[provider] = {
|
||||
...(config[provider] || {}),
|
||||
[configKey]: value,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -690,6 +690,8 @@
|
||||
|
||||
"empty_item": "Empty Item",
|
||||
|
||||
"log_in_with": "Log In with {provider}",
|
||||
|
||||
"filter": "Filter",
|
||||
"advanced_filter": "Advanced Filter",
|
||||
"operators": {
|
||||
|
||||
@@ -17,32 +17,14 @@
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- <template v-if="ssoProviders">
|
||||
<v-divider class="sso-divider" />
|
||||
|
||||
<v-button
|
||||
secondary
|
||||
class="sso-button"
|
||||
rounded
|
||||
icon
|
||||
v-for="provider in ssoProviders"
|
||||
:key="provider.name"
|
||||
:href="provider.link"
|
||||
>
|
||||
<img :src="provider.icon" :alt="provider.name" />
|
||||
</v-button>
|
||||
|
||||
<v-notice class="sso-notice" type="danger" v-if="ssoError">
|
||||
{{ translateAPIError(ssoError) }}
|
||||
</v-notice>
|
||||
</template> -->
|
||||
<sso-links />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, watch } from '@vue/composition-api';
|
||||
import router from '@/router';
|
||||
//
|
||||
import ssoLinks from '../sso-links.vue';
|
||||
import { login } from '@/auth';
|
||||
import { RequestError } from '@/api';
|
||||
import { translateAPIError } from '@/lang';
|
||||
@@ -55,12 +37,7 @@ type Credentials = {
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
ssoError: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
components: { ssoLinks },
|
||||
setup() {
|
||||
const loggingIn = ref(false);
|
||||
const email = ref<string | null>(null);
|
||||
@@ -81,19 +58,7 @@ export default defineComponent({
|
||||
return null;
|
||||
});
|
||||
|
||||
/** @todo fetch these from /auth/sso */
|
||||
// const ssoProviders = computed(() => {
|
||||
// const redirectURL = getRootPath() + `admin/login`;
|
||||
// return projectsStore.currentProject.value.sso.map((provider: { icon: string; name: string }) => {
|
||||
// return {
|
||||
// ...provider,
|
||||
// link: `/auth/sso/${provider.name}?mode=cookie&redirect_url=${redirectURL}`,
|
||||
// };
|
||||
// });
|
||||
// });
|
||||
|
||||
return {
|
||||
// ssoProviders,
|
||||
errorFormatted,
|
||||
error,
|
||||
email,
|
||||
@@ -162,19 +127,4 @@ export default defineComponent({
|
||||
color: var(--foreground-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.sso-divider {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.sso-button {
|
||||
img {
|
||||
width: 24px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.sso-notice {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
79
app/src/routes/login/components/sso-links.vue
Normal file
79
app/src/routes/login/components/sso-links.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="sso-links">
|
||||
<template v-if="providers.length > 0">
|
||||
<v-divider />
|
||||
|
||||
<a class="sso-link" v-for="provider in providers" :key="provider.name" :href="provider.link">
|
||||
{{ $t('log_in_with', { provider: provider.name }) }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, onMounted } from '@vue/composition-api';
|
||||
import api from '@/api';
|
||||
import getRootPath from '@/utils/get-root-path';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const providers = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
onMounted(() => fetchProviders());
|
||||
|
||||
return { providers };
|
||||
|
||||
async function fetchProviders() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await api.get('/auth/oauth/');
|
||||
|
||||
providers.value = response.data.data.map((providerName: string) => {
|
||||
return {
|
||||
name: providerName,
|
||||
link: `${getRootPath()}auth/oauth/${providerName.toLowerCase()}?redirect=${
|
||||
window.location.href
|
||||
}`,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-divider {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.sso-link {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
text-align: center;
|
||||
background-color: var(--background-normal);
|
||||
border-radius: var(--border-radius);
|
||||
transition: background var(--fast) var(--transition);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-normal-alt);
|
||||
}
|
||||
|
||||
& + & {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user