New OpenID and OAuth2 drivers (#8660)

* Moved over oauth impl to new interface

* Fixed most build issues and started addind schema to auth drivers

* Finished up OAuth2 and OpenID drivers

* Removed unused migration and utils

* Fixed minor todos

* Removed old oauth flow

* Changed oauth flow to re-use refresh token

* Added new oauth frontend

* Added font awesome social icons

* Updated authentication documentation

* Update api/src/auth/drivers/oauth2.ts

* Tested implementation and fixed incorrect validation

* Updated docs

* Improved OAuth error handling and re-enabled creating users with provider/identifier

* Removed Session config from docs

* Update app/src/components/v-icon/v-icon.vue

* Removed oauth need to define default roleID

* Added FormatTitle to SSO links

* Prevent local auth without password

* Store OAuth access token in session data

* Update docs/guides/api-config.md

* Fixed copy and removed fontawesome-vue dependency

* More docs fixes

* Crucialy importend type fiks

* Update package-lock

* Remove is-email-allowed check

In favor of more advanced version based on filtering coming later

* Fix JSON type casting

* Delete unused util

* Update type signature to include name

* Add warning when code isn't found in oauth url

and remove obsolete imports

* Auto-continue on successful SSO login

* Tweak type signature

* More type casting shenanigans

* Please the TS gods

* Check for missing token before crashing

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Aiden Foxx
2021-10-21 23:45:01 +02:00
committed by GitHub
parent 1b64b4472a
commit fa3b1171e8
36 changed files with 1747 additions and 822 deletions

View File

@@ -8,12 +8,15 @@
@click="emitClick"
>
<component :is="customIconName" v-if="customIconName" />
<socialIcon v-else-if="socialIconName" :name="socialIconName" />
<i v-else :class="{ filled }">{{ name }}</i>
</span>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
import { defineComponent, computed, h, PropType } from 'vue';
import { library, icon, findIconDefinition, IconName } from '@fortawesome/fontawesome-svg-core';
import { fab } from '@fortawesome/free-brands-svg-icons';
import useSizeClass, { sizeProps } from '@/composables/size-class';
import CustomIconDirectus from './custom-icons/directus.vue';
@@ -35,6 +38,8 @@ import CustomIconFolderMove from './custom-icons/folder_move.vue';
import CustomIconFolderLock from './custom-icons/folder_lock.vue';
import CustomIconLogout from './custom-icons/logout.vue';
library.add(fab);
const customIcons: string[] = [
'directus',
'bookmark_save',
@@ -56,6 +61,478 @@ const customIcons: string[] = [
'logout',
];
const socialIcons: string[] = [
'500px',
'accessible_icon',
'accusoft',
'acquisitions_incorporated',
'adn',
'adversal',
'affiliatetheme',
'airbnb',
'algolia',
'alipay',
'amazon',
'amazon_pay',
'amilia',
'android',
'angellist',
'angrycreative',
'angular',
'app_store',
'app_store_ios',
'apper',
'apple',
'apple_pay',
'artstation',
'asymmetrik',
'atlassian',
'audible',
'autoprefixer',
'avianex',
'aviato',
'aws',
'bandcamp',
'battle_net',
'behance',
'behance_square',
'bimobject',
'bitbucket',
'bitcoin',
'bity',
'black_tie',
'blackberry',
'blogger',
'blogger_b',
'bluetooth',
'bluetooth_b',
'bootstrap',
'btc',
'buffer',
'buromobelexperte',
'buy_n_large',
'buysellads',
'canadian_maple_leaf',
'cc_amazon_pay',
'cc_amex',
'cc_apple_pay',
'cc_diners_club',
'cc_discover',
'cc_jcb',
'cc_mastercard',
'cc_paypal',
'cc_stripe',
'cc_visa',
'centercode',
'centos',
'chrome',
'chromecast',
'cloudflare',
'cloudscale',
'cloudsmith',
'cloudversify',
'codepen',
'codiepie',
'confluence',
'connectdevelop',
'contao',
'cotton_bureau',
'cpanel',
'creative_commons',
'creative_commons_by',
'creative_commons_nc',
'creative_commons_nc_eu',
'creative_commons_nc_jp',
'creative_commons_nd',
'creative_commons_pd',
'creative_commons_pd_alt',
'creative_commons_remix',
'creative_commons_sa',
'creative_commons_sampling',
'creative_commons_sampling_plus',
'creative_commons_share',
'creative_commons_zero',
'critical_role',
'css3',
'css3_alt',
'cuttlefish',
'd_and_d',
'd_and_d_beyond',
'dailymotion',
'dashcube',
'deezer',
'delicious',
'deploydog',
'deskpro',
'dev',
'deviantart',
'dhl',
'diaspora',
'digg',
'digital_ocean',
'discord',
'discourse',
'dochub',
'docker',
'draft2digital',
'dribbble',
'dribbble_square',
'dropbox',
'drupal',
'dyalog',
'earlybirds',
'ebay',
'edge',
'edge_legacy',
'elementor',
'ello',
'ember',
'empire',
'envira',
'erlang',
'ethereum',
'etsy',
'evernote',
'expeditedssl',
'facebook',
'facebook_f',
'facebook_messenger',
'facebook_square',
'fantasy_flight_games',
'fedex',
'fedora',
'figma',
'firefox',
'firefox_browser',
'first_order',
'first_order_alt',
'firstdraft',
'flickr',
'flipboard',
'fly',
'font_awesome',
'font_awesome_alt',
'font_awesome_flag',
'fonticons',
'fonticons_fi',
'fort_awesome',
'fort_awesome_alt',
'forumbee',
'foursquare',
'free_code_camp',
'freebsd',
'fulcrum',
'galactic_republic',
'galactic_senate',
'get_pocket',
'gg',
'gg_circle',
'git',
'git_alt',
'git_square',
'github',
'github_alt',
'github_square',
'gitkraken',
'gitlab',
'gitter',
'glide',
'glide_g',
'gofore',
'goodreads',
'goodreads_g',
'google',
'google_drive',
'google_pay',
'google_play',
'google_plus',
'google_plus_g',
'google_plus_square',
'google_wallet',
'gratipay',
'grav',
'gripfire',
'grunt',
'guilded',
'gulp',
'hacker_news',
'hacker_news_square',
'hackerrank',
'hips',
'hire_a_helper',
'hive',
'hooli',
'hornbill',
'hotjar',
'houzz',
'html5',
'hubspot',
'ideal',
'imdb',
'innosoft',
'instagram',
'instagram_square',
'instalod',
'intercom',
'internet_explorer',
'invision',
'ioxhost',
'itch_io',
'itunes',
'itunes_note',
'java',
'jedi_order',
'jenkins',
'jira',
'joget',
'joomla',
'js',
'js_square',
'jsfiddle',
'kaggle',
'keybase',
'keycdn',
'kickstarter',
'kickstarter_k',
'korvue',
'laravel',
'lastfm',
'lastfm_square',
'leanpub',
'less',
'line',
'linkedin',
'linkedin_in',
'linode',
'linux',
'lyft',
'magento',
'mailchimp',
'mandalorian',
'markdown',
'mastodon',
'maxcdn',
'mdb',
'medapps',
'medium',
'medium_m',
'medrt',
'meetup',
'megaport',
'mendeley',
'microblog',
'microsoft',
'mix',
'mixcloud',
'mixer',
'mizuni',
'modx',
'monero',
'napster',
'neos',
'nimblr',
'node',
'node_js',
'npm',
'ns8',
'nutritionix',
'octopus_deploy',
'odnoklassniki',
'odnoklassniki_square',
'old_republic',
'opencart',
'openid',
'opera',
'optin_monster',
'orcid',
'osi',
'page4',
'pagelines',
'palfed',
'patreon',
'paypal',
'penny_arcade',
'perbyte',
'periscope',
'phabricator',
'phoenix_framework',
'phoenix_squadron',
'php',
'pied_piper',
'pied_piper_alt',
'pied_piper_hat',
'pied_piper_pp',
'pied_piper_square',
'pinterest',
'pinterest_p',
'pinterest_square',
'playstation',
'product_hunt',
'pushed',
'python',
'qq',
'quinscape',
'quora',
'r_project',
'raspberry_pi',
'ravelry',
'react',
'reacteurope',
'readme',
'rebel',
'red_river',
'reddit',
'reddit_alien',
'reddit_square',
'redhat',
'renren',
'replyd',
'researchgate',
'resolving',
'rev',
'rocketchat',
'rockrms',
'rust',
'safari',
'salesforce',
'sass',
'schlix',
'scribd',
'searchengin',
'sellcast',
'sellsy',
'servicestack',
'shirtsinbulk',
'shopify',
'shopware',
'simplybuilt',
'sistrix',
'sith',
'sketch',
'skyatlas',
'skype',
'slack',
'slack_hash',
'slideshare',
'snapchat',
'snapchat_ghost',
'snapchat_square',
'soundcloud',
'sourcetree',
'speakap',
'speaker_deck',
'spotify',
'squarespace',
'stack_exchange',
'stack_overflow',
'stackpath',
'staylinked',
'steam',
'steam_square',
'steam_symbol',
'sticker_mule',
'strava',
'stripe',
'stripe_s',
'studiovinari',
'stumbleupon',
'stumbleupon_circle',
'superpowers',
'supple',
'suse',
'swift',
'symfony',
'teamspeak',
'telegram',
'telegram_plane',
'tencent_weibo',
'the_red_yeti',
'themeco',
'themeisle',
'think_peaks',
'tiktok',
'trade_federation',
'trello',
'tumblr',
'tumblr_square',
'twitch',
'twitter',
'twitter_square',
'typo3',
'uber',
'ubuntu',
'uikit',
'umbraco',
'uncharted',
'uniregistry',
'unity',
'unsplash',
'untappd',
'ups',
'usb',
'usps',
'ussunnah',
'vaadin',
'viacoin',
'viadeo',
'viadeo_square',
'viber',
'vimeo',
'vimeo_square',
'vimeo_v',
'vine',
'vk',
'vnv',
'vuejs',
'watchman_monitoring',
'waze',
'weebly',
'weibo',
'weixin',
'whatsapp',
'whatsapp_square',
'whmcs',
'wikipedia_w',
'windows',
'wix',
'wizards_of_the_coast',
'wodu',
'wolf_pack_battalion',
'wordpress',
'wordpress_simple',
'wpbeginner',
'wpexplorer',
'wpforms',
'wpressr',
'xbox',
'xing',
'xing_square',
'y_combinator',
'yahoo',
'yammer',
'yandex',
'yandex_international',
'yarn',
'yelp',
'yoast',
'youtube',
'youtube_square',
'zhihu',
];
const SocialIcon = defineComponent({
props: {
name: {
type: String as PropType<IconName>,
required: true,
},
},
render() {
const socialIcon = icon(findIconDefinition({ prefix: 'fab', iconName: this.name }));
return h({ template: socialIcon?.html[0] });
},
});
export default defineComponent({
components: {
CustomIconDirectus,
@@ -76,6 +553,7 @@ export default defineComponent({
CustomIconFolderMove,
CustomIconFolderLock,
CustomIconLogout,
SocialIcon,
},
props: {
name: {
@@ -125,9 +603,15 @@ export default defineComponent({
return null;
});
const socialIconName = computed<string | null>(() => {
if (socialIcons.includes(props.name)) return props.name.replace(/_/g, '-');
return null;
});
return {
sizeClass,
customIconName,
socialIconName,
emitClick,
};
@@ -182,6 +666,11 @@ body {
display: inline-block;
color: inherit;
fill: currentColor;
&.svg-inline--fa {
width: auto;
height: auto;
}
}
&.has-click {

View File

@@ -69,3 +69,5 @@ export const MODULE_BAR_DEFAULT = [
locked: true,
},
];
export const AUTH_SSO_DRIVERS = ['oauth2', 'openid'];

View File

@@ -305,7 +305,13 @@ export default defineComponent({
];
const fieldsFiltered = computed(() => {
return fields.value.filter((field: Field) => fieldsDenyList.includes(field.field) === false);
return fields.value.filter((field: Field) => {
// These fields should only be editable when creating new users
if (!isNew.value && ['provider', 'external_identifier'].includes(field.field)) {
field.meta.readonly = true;
}
return !fieldsDenyList.includes(field.field);
});
});
const { formFields } = useFormFields(fieldsFiltered);

View File

@@ -17,7 +17,7 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref } from 'vue';
import { defineComponent, ref, onMounted } from 'vue';
import api from '@/api';
import { hydrate } from '@/hydrate';
@@ -37,6 +37,12 @@ export default defineComponent({
fetchUser();
onMounted(() => {
if ('continue' in router.currentRoute.value.query) {
hydrateAndLogin();
}
});
return { t, name, lastPage, loading, hydrateAndLogin };
async function fetchUser() {

View File

@@ -17,19 +17,20 @@
</router-link>
</div>
<sso-links />
<sso-links :providers="providers" />
</form>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref, computed, watch } from 'vue';
import { defineComponent, ref, computed, watch, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import ssoLinks from '../sso-links.vue';
import { login } from '@/auth';
import { RequestError } from '@/api';
import api, { RequestError } from '@/api';
import { translateAPIError } from '@/lang';
import { useUserStore } from '@/stores';
import { unexpectedError } from '@/utils/unexpected-error';
type Credentials = {
email: string;
@@ -50,8 +51,11 @@ export default defineComponent({
const error = ref<RequestError | string | null>(null);
const otp = ref<string | null>(null);
const requiresTFA = ref(false);
const providers = ref([]);
const userStore = useUserStore();
onMounted(() => fetchProviders());
watch(email, () => {
if (requiresTFA.value === true) requiresTFA.value = false;
});
@@ -68,7 +72,28 @@ export default defineComponent({
return null;
});
return { t, errorFormatted, error, email, password, onSubmit, loggingIn, translateAPIError, otp, requiresTFA };
return {
t,
errorFormatted,
error,
email,
password,
onSubmit,
loggingIn,
translateAPIError,
otp,
requiresTFA,
providers,
};
async function fetchProviders() {
try {
const response = await api.get('/auth');
providers.value = response.data.data;
} catch (err: any) {
unexpectedError(err);
}
}
async function onSubmit() {
if (email.value === null || password.value === null) return;

View File

@@ -1,10 +1,15 @@
<template>
<div class="sso-links">
<template v-if="providers && providers.length > 0">
<template v-if="ssoProviders.length > 0">
<v-divider />
<a v-for="provider in providers" :key="provider.name" class="sso-link" :href="provider.link">
{{ t('log_in_with', { provider: provider.name }) }}
<a v-for="provider in ssoProviders" :key="provider.name" class="sso-link" :href="provider.link">
<div class="sso-icon">
<v-icon :name="provider.icon" />
</div>
<div class="sso-title">
{{ t('log_in_with', { provider: provider.name }) }}
</div>
</a>
</template>
</div>
@@ -12,40 +17,40 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref, onMounted } from 'vue';
import api from '@/api';
import { defineComponent, ref, watch, toRefs, PropType } from 'vue';
import { AuthProvider } from '@/types';
import { AUTH_SSO_DRIVERS } from '@/constants';
import { getRootPath } from '@/utils/get-root-path';
import { unexpectedError } from '@/utils/unexpected-error';
import formatTitle from '@directus/format-title';
export default defineComponent({
setup() {
props: {
providers: {
type: Array as PropType<AuthProvider[]>,
default: () => [],
},
},
setup(props) {
const { t } = useI18n();
const providers = ref([]);
const loading = ref(false);
const { providers } = toRefs(props);
onMounted(() => fetchProviders());
const ssoProviders = ref<{ name: string; link: string; icon: string }[]>([]);
return { t, providers };
watch(providers, () => {
ssoProviders.value = providers.value
.filter((provider: AuthProvider) => AUTH_SSO_DRIVERS.includes(provider.driver))
.map((provider: AuthProvider) => ({
name: formatTitle(provider.name),
link: `${getRootPath()}auth/login/${provider.name}?redirect=${window.location.href.replace(
location.search,
'?continue'
)}`,
icon: provider.icon ?? 'account_circle',
}));
});
async function fetchProviders() {
loading.value = true;
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: any) {
unexpectedError(err);
} finally {
loading.value = false;
}
}
return { t, ssoProviders };
},
});
</script>
@@ -56,19 +61,39 @@ export default defineComponent({
}
.sso-link {
display: block;
$sso-link-border-width: 2px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: var(--input-height);
text-align: center;
background-color: var(--background-normal);
border: $sso-link-border-width var(--background-normal) solid;
border-radius: var(--border-radius);
transition: background var(--fast) var(--transition);
transition: border-color var(--fast) var(--transition);
.sso-icon {
display: flex;
align-items: center;
justify-content: center;
width: var(--input-height);
margin: -$sso-link-border-width;
background-color: var(--background-normal-alt);
border-radius: var(--border-radius);
span {
--v-icon-size: 28px;
}
}
.sso-title {
display: flex;
align-items: center;
padding: 0 16px 0 20px;
font-size: 16px;
}
&:hover {
background-color: var(--background-normal-alt);
border-color: var(--background-normal-alt);
}
& + & {

View File

@@ -2,3 +2,4 @@ export * from './collections';
export * from './error';
export * from './insights';
export * from './notifications';
export * from './login';

5
app/src/types/login.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface AuthProvider {
driver: string;
name: string;
icon?: string;
}