feat(authentication): implementation

CB-63
This commit is contained in:
Wroud
2020-05-11 15:33:13 +03:00
parent 1dcfdcf1ee
commit d0acdc7264
20 changed files with 782 additions and 6 deletions
@@ -15,7 +15,7 @@ type AuthCredentialInfo {
admin: Boolean
# This field must be shown in login form
user: Boolean
possibleValues: [String]!
possibleValues: [String]
encryption: AuthCredentialEncryption
}
@@ -0,0 +1,56 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observable } from 'mobx';
import { injectable } from '@dbeaver/core/di';
import { NotificationService } from '@dbeaver/core/eventsLog';
import { GraphQLService, UserAuthInfo } from '@dbeaver/core/sdk';
@injectable()
export class AuthInfoService {
@observable private user: UserAuthInfo | null = null;
constructor(
private graphQLService: GraphQLService,
private notificationService: NotificationService,
) { }
get userInfo() {
return this.user;
}
async login(provider: string, credentials: object): Promise<UserAuthInfo> {
if (this.user) {
throw new Error('User already logged in');
}
const { user } = await this.graphQLService.gql.authLogin({ provider, credentials });
this.user = user;
return this.user;
}
async logout() {
if (this.user) {
await this.graphQLService.gql.authLogout();
this.user = null;
}
}
async updateAuthInfo(): Promise<UserAuthInfo | null> {
try {
const { user } = await this.graphQLService.gql.getSessionUser();
this.user = user || null;
} catch (exception) {
this.notificationService.logException(exception, 'Can\'t load session user');
}
return this.user;
}
}
@@ -0,0 +1,29 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { injectable } from '@dbeaver/core/di';
import {
GraphQLService, CachedResource, AuthProviderInfo
} from '@dbeaver/core/sdk';
export type AuthProvider = Omit<AuthProviderInfo, 'configurationParameters'>
@injectable()
export class AuthProviderService {
readonly providers = new CachedResource([], this.refreshAsync.bind(this), data => !!data.length)
constructor(
private graphQLService: GraphQLService,
) { }
private async refreshAsync(data: AuthProvider[]): Promise<AuthProvider[]> {
const { providers } = await this.graphQLService.gql.getAuthProviders();
return providers;
}
}
@@ -7,14 +7,34 @@
*/
import { injectable } from '@dbeaver/core/di';
import { PermissionsService } from '@dbeaver/core/root';
import { ServerService } from '@dbeaver/core/root';
import { AuthInfoService } from './AuthInfoService';
import { AuthDialogService } from './Dialog/AuthDialogService';
@injectable()
export class AuthenticationService {
constructor(
private permissionsService: PermissionsService,
private serverService: ServerService,
private authDialogService: AuthDialogService,
private authInfoService: AuthInfoService,
) { }
register() {
async auth() {
if (this.isForceAuthentication()) {
await this.authDialogService.showLoginForm();
}
}
async isForceAuthentication() {
const config = await this.serverService.config.load();
if (!config) {
throw new Error('Can\'t configure Authentication');
}
const userInfo = await this.authInfoService.updateAuthInfo();
return !config.anonymousAccessEnabled
&& config.authenticationEnabled
&& !userInfo;
}
}
@@ -14,7 +14,7 @@ import { AuthenticationService } from './AuthenticationService';
export class Bootstrap {
constructor(private authenticationService: AuthenticationService) { }
bootstrap() {
this.authenticationService.register();
async bootstrap() {
return this.authenticationService.auth();
}
}
@@ -0,0 +1,142 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react';
import { useTabState, Tab as BaseTab, TabList } from 'reakit/Tab';
import styled, { css } from 'reshadow';
import { SubmittingForm, ErrorMessage } from '@dbeaver/core/blocks';
import { useController } from '@dbeaver/core/di';
import { DialogComponent, CommonDialogWrapper } from '@dbeaver/core/dialogs';
import { useTranslate } from '@dbeaver/core/localization';
import { composes, useStyles } from '@dbeaver/core/theming';
import { AuthDialogController } from './AuthDialogController';
import { AuthDialogFooter } from './AuthDialogFooter';
import { AuthProviderForm } from './AuthProviderForm/AuthProviderForm';
const styles = composes(
css`
BaseTab {
composes: theme-ripple theme-background-secondary theme-text-on-secondary from global;
}
ErrorMessage {
composes: theme-background-secondary from global;
}
`,
css`
custom-connection {
display: flex;
flex-direction: column;
box-sizing: border-box;
}
CommonDialogWrapper {
display: flex;
flex-direction: column;
max-height: 330px;
min-height: 330px;
}
SubmittingForm {
overflow: auto;
}
SubmittingForm, AuthProviderForm {
flex: 1;
display: flex;
flex-direction: column;
}
BaseTab {
outline: none;
}
TabList {
box-sizing: border-box;
display: inline-flex;
width: 100%;
padding-left: 24px;
}
BaseTab {
composes: theme-typography--body2 from global;
text-transform: uppercase;
padding: 12px 16px;
border-top: solid 2px transparent;
height: 48px;
&:global([aria-selected='true']) {
border-top-color: #fd1d48;
&:before {
display: none;
}
}
&:not(:global([aria-selected='true'])) {
cursor: pointer;
background-color: transparent !important;
}
}
AuthProviderForm {
flex-direction: column;
padding: 18px 24px;
}
ErrorMessage {
position: sticky;
bottom: 0;
padding: 8px 24px;
}
`
);
export const AuthDialog: DialogComponent<null, null> = observer(
function AuthDialog(props) {
const controller = useController(AuthDialogController, props.rejectDialog);
const translate = useTranslate();
const tab = useTabState({
selectedId: controller.provider?.id,
});
tab.selectedId = controller.provider?.id || null;
return styled(useStyles(styles))(
<CommonDialogWrapper
title={translate('sas')}
noBodyPadding
header={(
<TabList {...tab} aria-label="My tabs">
{controller.providers.map(provider => (
<BaseTab {...tab} key={provider.id} type='button' stopId={provider.id}>{provider.label}</BaseTab>
))}
</TabList>
)}
footer={(
<AuthDialogFooter
isAuthenticating={controller.isAuthenticating}
onLogin={controller.login}
/>
)}
onReject={props.rejectDialog}
>
<SubmittingForm onSubmit={controller.login}>
{controller.provider && (
<AuthProviderForm
provider={controller.provider}
credentials={controller.credentials}
authenticate={controller.isAuthenticating}
/>
)}
</SubmittingForm>
{controller.error.responseMessage && (
<ErrorMessage
text={controller.error.responseMessage}
hasDetails={controller.error.hasDetails}
onShowDetails={controller.showDetails}
/>
)}
</CommonDialogWrapper>
);
}
);
@@ -0,0 +1,107 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observable, computed } from 'mobx';
import { injectable, IInitializableController, IDestructibleController } from '@dbeaver/core/di';
import { CommonDialogService } from '@dbeaver/core/dialogs';
import { NotificationService } from '@dbeaver/core/eventsLog';
import { GQLErrorCatcher } from '@dbeaver/core/sdk';
import { ErrorDetailsDialog } from '@dbeaver/core/src/app';
import { AuthInfoService } from '../AuthInfoService';
import { AuthProviderService, AuthProvider } from '../AuthProviderService';
@injectable()
export class AuthDialogController implements IInitializableController, IDestructibleController {
@observable provider: AuthProvider | null = null
@observable isAuthenticating = false;
@observable credentials = {};
get isLoading() {
return this.authProviderService.providers.isLoading();
}
@computed get providers(): AuthProvider[] {
return this.authProviderService
.providers
.data
.concat()
.sort(this.compareProviders);
}
readonly error = new GQLErrorCatcher();
private isDistructed = false;
private close!: () => void;
constructor(
private notificationService: NotificationService,
private authProviderService: AuthProviderService,
private authInfoService: AuthInfoService,
private commonDialogService: CommonDialogService,
) { }
init(onClose: () => void) {
this.close = onClose;
this.loadProviders();
}
destruct(): void {
this.isDistructed = true;
}
login = async () => {
if (!this.provider || this.isAuthenticating) {
return;
}
this.isAuthenticating = true;
try {
await this.authInfoService.login(this.provider.id, this.credentials);
this.close();
} catch (exception) {
if (!this.error.catch(exception) || this.isDistructed) {
this.notificationService.logException(exception, 'Login failed');
}
} finally {
this.isAuthenticating = false;
}
}
selectProvider = (providerId: string) => {
this.provider = this.authProviderService
.providers.data.find(provider => provider.id === providerId) || null;
this.credentials = {};
}
showDetails = () => {
if (this.error.exception) {
this.commonDialogService.open(ErrorDetailsDialog, this.error.exception);
}
}
private async loadProviders() {
try {
await this.authProviderService.providers.load();
if (this.providers.length > 0) {
this.provider = this.providers[0];
}
} catch (exception) {
this.notificationService.logException(exception, 'Can\'t load auth providers');
}
}
private compareProviders = (providerA: AuthProvider, providerB: AuthProvider): number => {
if (providerA.isDefault === providerB.isDefault)
{
return providerA.label.localeCompare(providerB.label);
}
return +!!providerA.isDefault - +!!providerB.isDefault;
}
}
@@ -0,0 +1,57 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react';
import styled, { css } from 'reshadow';
import { Button } from '@dbeaver/core/blocks';
import { useTranslate } from '@dbeaver/core/localization';
const styles = css`
controls {
display: flex;
height: 100%;
align-items: center;
}
fill {
flex: 1;
}
Button:not(:first-child) {
margin-left: 24px;
}
`;
export type Props = {
isAuthenticating: boolean;
onLogin(): void;
}
export const AuthDialogFooter = observer(
function AuthDialogFooter({
isAuthenticating,
onLogin,
}: Props) {
const translate = useTranslate();
return styled(styles)(
<controls as="div">
<fill as="div"/>
<Button
type="button"
mod={['unelevated']}
onClick={onLogin}
disabled={isAuthenticating}
>
{translate('Login')}
</Button>
</controls>
);
}
);
@@ -0,0 +1,23 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { injectable } from '@dbeaver/core/di';
import { CommonDialogService } from '@dbeaver/core/dialogs';
import { AuthDialog } from './AuthDialog';
@injectable()
export class AuthDialogService {
constructor(
private commonDialogService: CommonDialogService,
) { }
async showLoginForm() {
await this.commonDialogService.open(AuthDialog, null);
}
}
@@ -0,0 +1,53 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react';
import { useCallback } from 'react';
import styled from 'reshadow';
import { InputField } from '@dbeaver/core/blocks';
import { useStyles } from '@dbeaver/core/theming';
import { AuthProvider } from '../../AuthProviderService';
import { formStyles } from './formStyles';
type Props = {
provider: AuthProvider;
credentials: any;
authenticate: boolean;
}
export const AuthProviderForm = observer(function AuthProviderForm({
provider,
credentials,
authenticate,
}: Props) {
// const translate = useTranslate();
const handleChange = useCallback((key: string, value: string) => {
credentials[key] = value;
}, [credentials]);
return styled(useStyles(formStyles))(
<>
{provider.credentialParameters.map(parameter => parameter.user && (
<group as="div" key={parameter.id}>
<InputField
type={parameter.encryption === 'none' ? 'text' : 'password'}
name={parameter.id}
value={credentials[parameter.id]}
onChange={value => handleChange(parameter.id, value)}
disabled={authenticate}
mod='surface'
>
{parameter.displayName}
</InputField>
</group>
))}
</>
);
});
@@ -0,0 +1,29 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { css } from 'reshadow';
export const formStyles = css`
sub-label {
composes: theme-typography--caption from global;
line-height: 14px;
}
group {
box-sizing: border-box;
display: flex;
margin: 0 12px;
}
InputField[|short] {
min-width: unset;
max-width: 250px;
}
hr {
margin-left: 24px;
margin-right: 24px;
}
`;
@@ -3,3 +3,6 @@ import { manifest } from './manifest';
export default manifest;
export * from './AuthenticationService';
export * from './AuthInfoService';
export * from './AuthProviderService';
export * from './Dialog/AuthDialogService';
@@ -9,7 +9,10 @@
import { IServiceInjector, PluginManifest } from '@dbeaver/core/di';
import { AuthenticationService } from './AuthenticationService';
import { AuthInfoService } from './AuthInfoService';
import { AuthProviderService } from './AuthProviderService';
import { Bootstrap } from './Bootstrap';
import { AuthDialogService } from './Dialog/AuthDialogService';
export const manifest: PluginManifest = {
info: {
@@ -18,6 +21,9 @@ export const manifest: PluginManifest = {
providers: [
AuthenticationService,
AuthInfoService,
AuthProviderService,
AuthDialogService,
],
async initialize(services: IServiceInjector) {
+1
View File
@@ -5,6 +5,7 @@ schema:
- ../../../server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls
- ../../../server/bundles/io.cloudbeaver.service.metadata/schema/service.metadata.graphqls
- ../../../server/bundles/io.cloudbeaver.service.data.transfer/schema/service.data.transfer.graphqls
- ../../../server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls
documents:
- ./src/**/*.gql
generates:
@@ -0,0 +1,12 @@
query authLogin(
$provider: ID!
$credentials: Object!
) {
user: authLogin(provider: $provider, credentials: $credentials) {
userId
displayName
authProvider
loginTime
message
}
}
@@ -0,0 +1,3 @@
query authLogout {
authLogout
}
@@ -0,0 +1,33 @@
query getAuthProviders {
providers: authProviders {
id
label
icon
description
isDefault
#configurationParameters {
# id
# displayName
# description
# category
# dataType
# value
# validValues
# defaultValue
# features
#}
credentialParameters {
id
displayName
description
editable
identifying
admin
user
possibleValues
encryption
}
}
}
@@ -0,0 +1,9 @@
query getSessionUser {
user: sessionUser {
userId
displayName
authProvider
loginTime
message
}
}
@@ -8,6 +8,8 @@ query serverConfig {
supportsCustomConnections
supportsConnectionBrowser
supportsWorkspaces
anonymousAccessEnabled
authenticationEnabled
supportedLanguages {
isoCode
displayName
+191
View File
@@ -31,6 +31,36 @@ export type AsyncTaskInfo = {
taskResult?: Maybe<Scalars["Object"]>;
};
export enum AuthCredentialEncryption {
None = "none",
Plain = "plain",
Hash = "hash",
}
export type AuthCredentialInfo = {
id: Scalars["ID"];
displayName: Scalars["String"];
description?: Maybe<Scalars["String"]>;
editable?: Maybe<Scalars["Boolean"]>;
identifying?: Maybe<Scalars["Boolean"]>;
/** This field must be shown in admin panel */
admin?: Maybe<Scalars["Boolean"]>;
/** This field must be shown in login form */
user?: Maybe<Scalars["Boolean"]>;
possibleValues?: Maybe<Array<Maybe<Scalars["String"]>>>;
encryption?: Maybe<AuthCredentialEncryption>;
};
export type AuthProviderInfo = {
id: Scalars["ID"];
label: Scalars["String"];
icon?: Maybe<Scalars["ID"]>;
description?: Maybe<Scalars["String"]>;
isDefault?: Maybe<Scalars["Boolean"]>;
configurationParameters: Array<Maybe<ObjectPropertyInfo>>;
credentialParameters: Array<Maybe<AuthCredentialInfo>>;
};
/** Configuration of particular connection. Used for new connection create. Includes auth info */
export type ConnectionConfig = {
name?: Maybe<Scalars["String"]>;
@@ -387,6 +417,10 @@ export type Query = {
sqlDialectInfo?: Maybe<SqlDialectInfo>;
sqlListContexts?: Maybe<Array<Maybe<SqlContextInfo>>>;
sqlCompletionProposals?: Maybe<Array<Maybe<SqlCompletionProposal>>>;
authLogin?: Maybe<UserAuthInfo>;
authLogout?: Maybe<Scalars["Boolean"]>;
sessionUser?: Maybe<UserAuthInfo>;
authProviders: Array<Maybe<AuthProviderInfo>>;
/** Available transfer processors */
dataTransferAvailableStreamProcessors?: Maybe<
Array<Maybe<DataTransferProcessorInfo>>
@@ -447,6 +481,11 @@ export type QuerySqlCompletionProposalsArgs = {
maxResults?: Maybe<Scalars["Int"]>;
};
export type QueryAuthLoginArgs = {
provider: Scalars["ID"];
credentials: Scalars["Object"];
};
export type QueryDataTransferExportDataFromContainerArgs = {
connectionId: Scalars["ID"];
containerNodePath: Scalars["ID"];
@@ -594,6 +633,18 @@ export type SqlResultSet = {
hasMoreData?: Maybe<Scalars["Boolean"]>;
};
export type UserAuthInfo = {
/** User unique identifier */
userId: Scalars["String"];
/** Human readable display name. May be null */
displayName?: Maybe<Scalars["String"]>;
/** Auth provider ID */
authProvider: Scalars["String"];
loginTime: Scalars["DateTime"];
/** Optional login message */
message?: Maybe<Scalars["String"]>;
};
export type WebServiceConfig = {
id: Scalars["String"];
name: Scalars["String"];
@@ -855,6 +906,65 @@ export type ChangeSessionLanguageMutation = Pick<
"changeSessionLanguage"
>;
export type AuthLoginQueryVariables = {
provider: Scalars["ID"];
credentials: Scalars["Object"];
};
export type AuthLoginQuery = {
user: Maybe<
Pick<
UserAuthInfo,
"userId" | "displayName" | "authProvider" | "loginTime" | "message"
>
>;
};
export type AuthLogoutQueryVariables = {};
export type AuthLogoutQuery = Pick<Query, "authLogout">;
export type GetAuthProvidersQueryVariables = {};
export type GetAuthProvidersQuery = {
providers: Array<
Maybe<
Pick<
AuthProviderInfo,
"id" | "label" | "icon" | "description" | "isDefault"
> & {
credentialParameters: Array<
Maybe<
Pick<
AuthCredentialInfo,
| "id"
| "displayName"
| "description"
| "editable"
| "identifying"
| "admin"
| "user"
| "possibleValues"
| "encryption"
>
>
>;
}
>
>;
};
export type GetSessionUserQueryVariables = {};
export type GetSessionUserQuery = {
user: Maybe<
Pick<
UserAuthInfo,
"userId" | "displayName" | "authProvider" | "loginTime" | "message"
>
>;
};
export type AsyncExportTaskStatusMutationVariables = {
taskId: Scalars["String"];
};
@@ -1278,6 +1388,8 @@ export type ServerConfigQuery = {
| "supportsCustomConnections"
| "supportsConnectionBrowser"
| "supportsWorkspaces"
| "anonymousAccessEnabled"
| "authenticationEnabled"
> & {
supportedLanguages: Array<
Maybe<Pick<ServerLanguage, "isoCode" | "displayName" | "nativeName">>
@@ -1513,6 +1625,55 @@ export const ChangeSessionLanguageDocument = gql`
changeSessionLanguage(locale: $locale)
}
`;
export const AuthLoginDocument = gql`
query authLogin($provider: ID!, $credentials: Object!) {
user: authLogin(provider: $provider, credentials: $credentials) {
userId
displayName
authProvider
loginTime
message
}
}
`;
export const AuthLogoutDocument = gql`
query authLogout {
authLogout
}
`;
export const GetAuthProvidersDocument = gql`
query getAuthProviders {
providers: authProviders {
id
label
icon
description
isDefault
credentialParameters {
id
displayName
description
editable
identifying
admin
user
possibleValues
encryption
}
}
}
`;
export const GetSessionUserDocument = gql`
query getSessionUser {
user: sessionUser {
userId
displayName
authProvider
loginTime
message
}
}
`;
export const AsyncExportTaskStatusDocument = gql`
mutation asyncExportTaskStatus($taskId: String!) {
taskInfo: asyncTaskStatus(id: $taskId) {
@@ -1926,6 +2087,8 @@ export const ServerConfigDocument = gql`
supportsCustomConnections
supportsConnectionBrowser
supportsWorkspaces
anonymousAccessEnabled
authenticationEnabled
supportedLanguages {
isoCode
displayName
@@ -2089,6 +2252,34 @@ export function getSdk(client: GraphQLClient) {
variables,
);
},
authLogin(variables: AuthLoginQueryVariables): Promise<AuthLoginQuery> {
return client.request<AuthLoginQuery>(
print(AuthLoginDocument),
variables,
);
},
authLogout(variables?: AuthLogoutQueryVariables): Promise<AuthLogoutQuery> {
return client.request<AuthLogoutQuery>(
print(AuthLogoutDocument),
variables,
);
},
getAuthProviders(
variables?: GetAuthProvidersQueryVariables,
): Promise<GetAuthProvidersQuery> {
return client.request<GetAuthProvidersQuery>(
print(GetAuthProvidersDocument),
variables,
);
},
getSessionUser(
variables?: GetSessionUserQueryVariables,
): Promise<GetSessionUserQuery> {
return client.request<GetSessionUserQuery>(
print(GetSessionUserDocument),
variables,
);
},
asyncExportTaskStatus(
variables: AsyncExportTaskStatusMutationVariables,
): Promise<AsyncExportTaskStatusMutation> {