From c620e6a9301fcaed11b7269a88978bd46cc21fa2 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Sat, 17 Aug 2019 12:13:27 +0300 Subject: [PATCH 01/11] feat(social): add simple google widget --- front/src/features/profile/Profile.tsx | 5 ++ front/src/ui/auth-widget/google/Google.tsx | 50 ++++++++++++++++++++ front/src/ui/auth-widget/google/index.ts | 1 + shared/models/user/external/GoogleProfile.ts | 6 +++ 4 files changed, 62 insertions(+) create mode 100644 front/src/ui/auth-widget/google/Google.tsx create mode 100644 front/src/ui/auth-widget/google/index.ts create mode 100644 shared/models/user/external/GoogleProfile.ts diff --git a/front/src/features/profile/Profile.tsx b/front/src/features/profile/Profile.tsx index 5e516820..7e61d54b 100644 --- a/front/src/features/profile/Profile.tsx +++ b/front/src/features/profile/Profile.tsx @@ -15,6 +15,7 @@ import { Card } from '&front/ui/components/layout/card'; import { Container } from '&front/ui/components/layout/container'; import { PageHeader } from '&front/ui/components/layout/page-header'; import { useNotifyAlert } from '&front/ui/hooks/useNotifyAlert'; +import { Google } from '&front/ui/auth-widget/google'; import { Currency } from '&shared/enum/Currency'; import { pushRoute } from '../routing'; @@ -75,6 +76,10 @@ export const Profile = () => { setOnMonday(!!v)} /> + + + + ); diff --git a/front/src/ui/auth-widget/google/Google.tsx b/front/src/ui/auth-widget/google/Google.tsx new file mode 100644 index 00000000..0674ec24 --- /dev/null +++ b/front/src/ui/auth-widget/google/Google.tsx @@ -0,0 +1,50 @@ +import Head from 'next/head'; +import { useEffect } from 'react'; +import { useState } from 'react'; + +import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; + +const googleClientId = '619616345812-bi543g7ojta4uqq4kk1ccp428pik8hp8'; + +interface Props { + onLogin: (profile: GoogleProfile) => any; +} + +export const Google = ({ onLogin }: Props) => { + const [isClient, setClient] = useState(false); + + useEffect(() => { + (window as any).onSignIn = (googleUser: any) => { + const rawProfile = googleUser.getBasicProfile(); + + const profile: GoogleProfile = { + id: rawProfile.getId(), + name: rawProfile.getName(), + photo: rawProfile.getImageUrl(), + email: rawProfile.getEmail(), + }; + + onLogin(profile); + }; + + setClient(true); + }, []); + + return ( + <> + + + + + + {isClient &&
} + + ); +}; diff --git a/front/src/ui/auth-widget/google/index.ts b/front/src/ui/auth-widget/google/index.ts new file mode 100644 index 00000000..542fbc4c --- /dev/null +++ b/front/src/ui/auth-widget/google/index.ts @@ -0,0 +1 @@ +export { Google } from './Google'; diff --git a/shared/models/user/external/GoogleProfile.ts b/shared/models/user/external/GoogleProfile.ts new file mode 100644 index 00000000..b7531fda --- /dev/null +++ b/shared/models/user/external/GoogleProfile.ts @@ -0,0 +1,6 @@ +export interface GoogleProfile { + readonly id: string; + readonly name: string; + readonly photo?: string; + readonly email?: string; +} From cb5136ed1f4e065ae5bd045f18ca8231c1b36176 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Sat, 17 Aug 2019 13:57:59 +0300 Subject: [PATCH 02/11] feat(social): add validating google auth payload --- back/.env.dist | 5 +- back/package.json | 4 +- back/src/user/application/SocialBinder.ts | 28 ++++ .../InvalidSocialRequestException.ts | 9 ++ .../application/social/GoogleValidator.ts | 45 +++++++ .../http/controller/SocialController.ts | 33 +++++ .../http/request/GoogleBindRequest.ts | 24 ++++ back/src/user/user.module.ts | 7 +- front/src/domain/user/actions/bindGoogle.ts | 8 ++ front/src/features/profile/Profile.tsx | 11 +- front/src/ui/auth-widget/google/Google.tsx | 4 +- shared/models/user/external/GoogleProfile.ts | 1 + yarn.lock | 122 +++++++++++++++++- 13 files changed, 287 insertions(+), 14 deletions(-) create mode 100644 back/src/user/application/SocialBinder.ts create mode 100644 back/src/user/application/exception/InvalidSocialRequestException.ts create mode 100644 back/src/user/application/social/GoogleValidator.ts create mode 100644 back/src/user/presentation/http/controller/SocialController.ts create mode 100644 back/src/user/presentation/http/request/GoogleBindRequest.ts create mode 100644 front/src/domain/user/actions/bindGoogle.ts diff --git a/back/.env.dist b/back/.env.dist index a8df0719..ecdf6c6b 100644 --- a/back/.env.dist +++ b/back/.env.dist @@ -11,4 +11,7 @@ PRODUCTION_READY=1 MANNY_API_KEY=Secret TELEGRAM_BOT_TOKEN=Secret -API_PUBLIC_URL=https://api.checkmoney.space \ No newline at end of file +API_PUBLIC_URL=https://api.checkmoney.space + +GOOGLE_CLIENT_ID=Secret +GOOGLE_CLIENT_SECRET=Secret \ No newline at end of file diff --git a/back/package.json b/back/package.json index b9284f6c..39d0c25f 100644 --- a/back/package.json +++ b/back/package.json @@ -16,10 +16,12 @@ "@nestjs/jwt": "^0.2.1", "@nestjs/swagger": "^2.5.1", "@nestjs/typeorm": "^5.2.2", - "@solid-soda/config": "^1.1.6", + "@solid-soda/config": "^2.0.0", "@solid-soda/evolutions": "^0.1.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", + "fast-deep-equal": "^2.0.1", + "google-auth-library": "^5.2.0", "handlebars": "^4.1.0", "md5": "^2.2.1", "morgan": "^1.9.1", diff --git a/back/src/user/application/SocialBinder.ts b/back/src/user/application/SocialBinder.ts new file mode 100644 index 00000000..42eb1e9a --- /dev/null +++ b/back/src/user/application/SocialBinder.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; + +import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; + +import { UserRepository } from '../domain/UserRepository'; +import { InvalidSocialRequestException } from './exception/InvalidSocialRequestException'; +import { GoogleValidator } from './social/GoogleValidator'; + +@Injectable() +export class SocialBinder { + constructor( + private readonly googleValidator: GoogleValidator, + private readonly userRepo: UserRepository, + ) {} + + async bindGoogle(login: string, profile: GoogleProfile) { + const [valid, user] = await Promise.all([ + this.googleValidator.isValid(profile), + this.userRepo.getOne(login), + ]); + + if (!valid) { + throw new InvalidSocialRequestException(login, 'Google', profile); + } + + // TODO: bind + } +} diff --git a/back/src/user/application/exception/InvalidSocialRequestException.ts b/back/src/user/application/exception/InvalidSocialRequestException.ts new file mode 100644 index 00000000..18c40616 --- /dev/null +++ b/back/src/user/application/exception/InvalidSocialRequestException.ts @@ -0,0 +1,9 @@ +export class InvalidSocialRequestException extends Error { + public constructor( + public readonly login: string, + public readonly social: string, + public readonly payload: any, + ) { + super(`Invalid credentials for ${login} from ${social}`); + } +} diff --git a/back/src/user/application/social/GoogleValidator.ts b/back/src/user/application/social/GoogleValidator.ts new file mode 100644 index 00000000..b720a1a9 --- /dev/null +++ b/back/src/user/application/social/GoogleValidator.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { OAuth2Client } from 'google-auth-library'; +import * as deepEqual from 'fast-deep-equal'; + +import { Configuration } from '&back/config/Configuration'; +import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; + +@Injectable() +export class GoogleValidator { + private readonly client: OAuth2Client; + private readonly googleClientId: string; + + constructor(config: Configuration) { + this.googleClientId = config.getStringOrThrow('GOOGLE_CLIENT_ID'); + + const googleClientSecret = config.getStringOrThrow('GOOGLE_CLIENT_SECRET'); + + this.client = new OAuth2Client(this.googleClientId, googleClientSecret); + } + + async isValid(profile: GoogleProfile): Promise { + const { token } = profile; + + try { + const ticket = await this.client.verifyIdToken({ + idToken: profile.token, + audience: this.googleClientId, + }); + + const payload = ticket.getPayload(); + + const payloadProfile: GoogleProfile = { + token, + name: payload.name, + id: payload.sub, + photo: payload.picture, + email: payload.email, + }; + + return deepEqual(profile, payloadProfile); + } catch (error) { + return false; + } + } +} diff --git a/back/src/user/presentation/http/controller/SocialController.ts b/back/src/user/presentation/http/controller/SocialController.ts new file mode 100644 index 00000000..3f9d9e2d --- /dev/null +++ b/back/src/user/presentation/http/controller/SocialController.ts @@ -0,0 +1,33 @@ +import { Body, Controller } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiOkResponse, + ApiOperation, + ApiUseTags, +} from '@nestjs/swagger'; + +import { SocialBinder } from '&back/user/application/SocialBinder'; +import { PostNoCreate } from '&back/utils/presentation/http/PostNoCreate'; +import { TokenPayloadModel } from '&shared/models/user/TokenPayloadModel'; + +import { CurrentUser } from '../decorator/CurrentUser'; +import { GoogleBindRequest } from '../request/GoogleBindRequest'; +import { OnlyForUsers } from '../security/OnlyForUsers'; + +@Controller('user/bind') +@ApiUseTags('user') +@OnlyForUsers() +export class SocialController { + public constructor(private readonly binder: SocialBinder) {} + + @PostNoCreate('google') + @ApiOperation({ title: 'Bind Google profile to exist user account' }) + @ApiOkResponse({ description: 'Valid request', type: GoogleBindRequest }) + @ApiBadRequestResponse({ description: 'Invalid request' }) + public async signIn( + @Body() request: GoogleBindRequest, + @CurrentUser() { login }: TokenPayloadModel, + ): Promise { + await this.binder.bindGoogle(login, request); + } +} diff --git a/back/src/user/presentation/http/request/GoogleBindRequest.ts b/back/src/user/presentation/http/request/GoogleBindRequest.ts new file mode 100644 index 00000000..ed96e125 --- /dev/null +++ b/back/src/user/presentation/http/request/GoogleBindRequest.ts @@ -0,0 +1,24 @@ +import { ApiModelProperty } from '@nestjs/swagger'; + +import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; + +export class GoogleBindRequest implements GoogleProfile { + @ApiModelProperty({ example: '118386561850719338466' }) + public readonly id: string; + + @ApiModelProperty({ example: 'Игорь Камышев' }) + public readonly name: string; + + @ApiModelProperty({ + example: + 'https://lh5.googleusercontent.com/-jM0jW1cJaCc/AAAAAAAAAAI/AAAAAAACM-E/l7C1Y9QNEMw/s96-c/photo.jpg', + required: false, + }) + public readonly photo?: string; + + @ApiModelProperty({ example: 'garik.novel@gmail.com', required: false }) + public readonly email?: string; + + @ApiModelProperty({ example: 'hkdsfkllkds' }) + public readonly token: string; +} diff --git a/back/src/user/user.module.ts b/back/src/user/user.module.ts index 43298972..cebb7bde 100644 --- a/back/src/user/user.module.ts +++ b/back/src/user/user.module.ts @@ -9,6 +9,8 @@ import { UtilsModule } from '&back/utils/utils.module'; import { Authenticator } from './application/Authenticator'; import { ProfileEditor } from './application/ProfileEditor'; import { Registrator } from './application/Registrator'; +import { GoogleValidator } from './application/social/GoogleValidator'; +import { SocialBinder } from './application/SocialBinder'; import { User } from './domain/User.entity'; import { UserRepository } from './domain/UserRepository'; import { JwtOptionsFactory } from './infrastructure/JwtOptionsFactory'; @@ -16,6 +18,7 @@ import { BcryptPasswordEncoder } from './infrastructure/PasswordEncoder/BcryptPa import { PasswordEncoder } from './infrastructure/PasswordEncoder/PasswordEncoder'; import { AuthController } from './presentation/http/controller/AuthController'; import { ProfileController } from './presentation/http/controller/ProfileController'; +import { SocialController } from './presentation/http/controller/SocialController'; import { InvalidCredentialsFilter } from './presentation/http/filter/InvalidCredentialsFilter'; import { LoginAlreadyTakenFilter } from './presentation/http/filter/LoginAlreadyTakenFilter'; import { JwtGuard } from './presentation/http/security/JwtGuard'; @@ -35,12 +38,14 @@ import { IsKnownUser } from './presentation/telegram/transformer/IsKnownUser'; useClass: JwtOptionsFactory, }), ], - controllers: [AuthController, ProfileController], + controllers: [AuthController, ProfileController, SocialController], providers: [ { provide: PasswordEncoder, useClass: BcryptPasswordEncoder, }, + SocialBinder, + GoogleValidator, LoginAlreadyTakenFilter.provider(), InvalidCredentialsFilter.provider(), Authenticator, diff --git a/front/src/domain/user/actions/bindGoogle.ts b/front/src/domain/user/actions/bindGoogle.ts new file mode 100644 index 00000000..78ba7f60 --- /dev/null +++ b/front/src/domain/user/actions/bindGoogle.ts @@ -0,0 +1,8 @@ +import { fetchOrFail } from '&front/domain/store'; +import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; + +export const bindGoogle = (profile: GoogleProfile) => + fetchOrFail(undefined, async (_, getApi) => { + // TODO: react on response + await getApi().client.post('/user/bind/google', profile); + }); diff --git a/front/src/features/profile/Profile.tsx b/front/src/features/profile/Profile.tsx index 7e61d54b..f9acb3c3 100644 --- a/front/src/features/profile/Profile.tsx +++ b/front/src/features/profile/Profile.tsx @@ -2,11 +2,13 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useTranslation } from '&front/domain/i18n'; import { useMemoState, useThunk } from '&front/domain/store'; +import { bindGoogle } from '&front/domain/user/actions/bindGoogle'; import { fetchUserProfile } from '&front/domain/user/actions/fetchUserProfile'; import { setDefaultCurrency } from '&front/domain/user/actions/setDefaultCurrency'; import { setWeekStart } from '&front/domain/user/actions/setWeekStart'; import { signOut } from '&front/domain/user/actions/signOut'; import { getProfile } from '&front/domain/user/selectors/getProfile'; +import { Google } from '&front/ui/auth-widget/google'; import { CurrencySwitch } from '&front/ui/components/controls/currency-switch'; import { Button } from '&front/ui/components/form/button'; import { Checkbox } from '&front/ui/components/form/checkbox'; @@ -15,8 +17,8 @@ import { Card } from '&front/ui/components/layout/card'; import { Container } from '&front/ui/components/layout/container'; import { PageHeader } from '&front/ui/components/layout/page-header'; import { useNotifyAlert } from '&front/ui/hooks/useNotifyAlert'; -import { Google } from '&front/ui/auth-widget/google'; import { Currency } from '&shared/enum/Currency'; +import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; import { pushRoute } from '../routing'; import * as styles from './Profile.css'; @@ -37,6 +39,11 @@ export const Profile = () => { const saved = useCallback(() => notify('Saved'), [notify]); + const handleGoogleLogin = useCallback( + (profile: GoogleProfile) => dispatch(bindGoogle(profile)), + [], + ); + useEffect(() => { if (currency !== defaultCurrency) { dispatch(setDefaultCurrency(currency)).then(saved); @@ -78,7 +85,7 @@ export const Profile = () => { - + diff --git a/front/src/ui/auth-widget/google/Google.tsx b/front/src/ui/auth-widget/google/Google.tsx index 0674ec24..e0b9c6f3 100644 --- a/front/src/ui/auth-widget/google/Google.tsx +++ b/front/src/ui/auth-widget/google/Google.tsx @@ -1,6 +1,5 @@ import Head from 'next/head'; -import { useEffect } from 'react'; -import { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; @@ -22,6 +21,7 @@ export const Google = ({ onLogin }: Props) => { name: rawProfile.getName(), photo: rawProfile.getImageUrl(), email: rawProfile.getEmail(), + token: googleUser.getAuthResponse().id_token, }; onLogin(profile); diff --git a/shared/models/user/external/GoogleProfile.ts b/shared/models/user/external/GoogleProfile.ts index b7531fda..2a8cf1d6 100644 --- a/shared/models/user/external/GoogleProfile.ts +++ b/shared/models/user/external/GoogleProfile.ts @@ -1,6 +1,7 @@ export interface GoogleProfile { readonly id: string; readonly name: string; + readonly token: string; readonly photo?: string; readonly email?: string; } diff --git a/yarn.lock b/yarn.lock index 7f7dd410..1243aaeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1813,14 +1813,14 @@ dependencies: any-observable "^0.3.0" -"@solid-soda/config@^1.1.6": - version "1.1.6" - resolved "https://registry.yarnpkg.com/@solid-soda/config/-/config-1.1.6.tgz#c4453be9337b10f8dd385f9b4b61b04ed7d4044b" - integrity sha512-ji6SiRez4HNOC6W4F2IbugAnx+EdXi3HzOPox1UeWpjBiCia78B2NneJNhgAvZDSQM51Jc4YAVQ/BWRZ6Pg1qg== +"@solid-soda/config@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@solid-soda/config/-/config-2.0.0.tgz#e3343ca51345e437946585c8ac99c2f044abd578" + integrity sha512-jJ3Q7iyNGrbekZm+h4Bx39S4jPRfCALEOKsm/dSZpFMI0jf0nxCOsFd6/kcGZDVonpo/68Rz9TEe19cx+ephUw== dependencies: change-case "^3.1.0" dotenv "^6.2.0" - tsoption "^0.7.0" + nanoption "^1.0.1" "@solid-soda/evolutions@^0.1.0": version "0.1.0" @@ -2638,6 +2638,13 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.5: version "1.3.5" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2" @@ -3091,6 +3098,11 @@ arrify@^1.0.0, arrify@^1.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= +arrify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" + integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== + asap@~2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" @@ -3551,6 +3563,11 @@ base64-js@^1.0.2: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3" integrity sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw== +base64-js@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" + integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== + base@^0.11.1: version "0.11.2" resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" @@ -3603,6 +3620,11 @@ big.js@^5.2.2: resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== +bignumber.js@^7.0.0: + version "7.2.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-7.2.1.tgz#80c048759d826800807c4bfd521e50edbba57a5f" + integrity sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ== + bigrig@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/bigrig/-/bigrig-1.3.0.tgz#1f2a4822082c514659d06d7873ff4386b0d179e5" @@ -6659,6 +6681,11 @@ event-source-polyfill@0.0.12: resolved "https://registry.yarnpkg.com/event-source-polyfill/-/event-source-polyfill-0.0.12.tgz#e539cd67fdef2760a16aa5262fa98134df52e3af" integrity sha1-5TnNZ/3vJ2ChaqUmL6mBNN9S468= +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter2@5.0.1, eventemitter2@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-5.0.1.tgz#6197a095d5fb6b57e8942f6fd7eaad63a09c9452" @@ -6923,7 +6950,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: assign-symbols "^1.0.0" is-extendable "^1.0.1" -extend@3, extend@^3.0.0, extend@~3.0.2: +extend@3, extend@^3.0.0, extend@^3.0.2, extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -7044,6 +7071,11 @@ fast-safe-stringify@1.2.0: resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-1.2.0.tgz#ebd42666fd18fe4f2ba4f0d295065f3f85cade96" integrity sha1-69QmZv0Y/k8rpPDSlQZfP4XK3pY= +fast-text-encoding@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.0.tgz#3e5ce8293409cfaa7177a71b9ca84e1b1e6f25ef" + integrity sha512-R9bHCvweUxxwkDwhjav5vxpFvdPGlVngtqmx4pIZfSUhM/Q4NiIUHB456BAf+Q1Nwu3HEZYONtu+Rya+af4jiQ== + fastparse@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.2.tgz#91728c5a5942eced8531283c79441ee4122c35a9" @@ -7573,6 +7605,24 @@ gauge@~2.7.3: strip-ansi "^3.0.1" wide-align "^1.1.0" +gaxios@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-2.0.1.tgz#2ca1c9eb64c525d852048721316c138dddf40708" + integrity sha512-c1NXovTxkgRJTIgB2FrFmOFg4YIV6N/bAa4f/FZ4jIw13Ql9ya/82x69CswvotJhbV3DiGnlTZwoq2NVXk2Irg== + dependencies: + abort-controller "^3.0.0" + extend "^3.0.2" + https-proxy-agent "^2.2.1" + node-fetch "^2.3.0" + +gcp-metadata@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-2.0.1.tgz#7f4657b0f52af1c9f6f3a1e0f54a24d72bbdf84f" + integrity sha512-nrbLj5O1MurvpLC/doFwzdTfKnmYGDYXlY/v7eQ4tJNVIvQXbOK672J9UFbradbtmuTkyHzjpzD8HD0Djz0LWw== + dependencies: + gaxios "^2.0.0" + json-bigint "^0.3.0" + generic-names@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/generic-names/-/generic-names-1.0.3.tgz#2d786a121aee508876796939e8e3bff836c20917" @@ -7908,6 +7958,27 @@ gonzales-pe@^4.2.3: dependencies: minimist "1.1.x" +google-auth-library@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-5.2.0.tgz#fff161ec6ad8630c60b37324f78347e11712512b" + integrity sha512-I2726rgOedQ06HgTvoNvBeRCzy5iFe6z3khwj6ugfRd1b0VHwnTYKl/3t2ytOTo7kKc6KivYIBsCIdZf2ep67g== + dependencies: + arrify "^2.0.0" + base64-js "^1.3.0" + fast-text-encoding "^1.0.0" + gaxios "^2.0.0" + gcp-metadata "^2.0.0" + gtoken "^4.0.0" + jws "^3.1.5" + lru-cache "^5.0.0" + +google-p12-pem@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-2.0.1.tgz#509f9415e50c9bdf76de8150a825f9e97cba2c57" + integrity sha512-6h6x+eBX3k+IDSe/c8dVYmn8Mzr1mUcmKC9MdUSwaBkFAXlqBEnwFWmSFgGC+tcqtsLn73BDP/vUNWEehf1Rww== + dependencies: + node-forge "^0.8.0" + got@^6.7.1: version "6.7.1" resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0" @@ -7955,6 +8026,16 @@ growly@^1.3.0: resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= +gtoken@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-4.0.0.tgz#42b63a935a03a61eedf0ec14f74f6875bad627bd" + integrity sha512-XaRCfHJxhj06LmnWNBzVTAr85NfAErq0W1oabkdqwbq3uL/QTB1kyvGog361Uu2FMG/8e3115sIy/97Rnd4GjQ== + dependencies: + gaxios "^2.0.0" + google-p12-pem "^2.0.0" + jws "^3.1.5" + mime "^2.2.0" + gud@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" @@ -10128,6 +10209,13 @@ jsesc@~0.5.0: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= +json-bigint@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-0.3.0.tgz#0ccd912c4b8270d05f056fbd13814b53d3825b1e" + integrity sha1-DM2RLEuCcNBfBW+9E4FLU9OCWx4= + dependencies: + bignumber.js "^7.0.0" + json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" @@ -10838,7 +10926,7 @@ lru-cache@^4.0.1, lru-cache@^4.1.1, lru-cache@^4.1.2, lru-cache@^4.1.5: pseudomap "^1.0.2" yallist "^2.1.2" -lru-cache@^5.1.1: +lru-cache@^5.0.0, lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== @@ -11228,6 +11316,11 @@ mime@^2.0.3: resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.3.tgz#229687331e86f68924e6cb59e1cdd937f18275fe" integrity sha512-QgrPRJfE+riq5TPZMcHZOtm8c6K/yYrMbKIoRfapfiGLxS8OTeIfRhUGW5LU7MlRa52KOAGCfUNruqLrIBvWZw== +mime@^2.2.0: + version "2.4.4" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" + integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== + mime@^2.3.1: version "2.4.0" resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.0.tgz#e051fd881358585f3279df333fe694da0bcffdd6" @@ -11535,6 +11628,11 @@ nanomerge@^0.2.0: dependencies: nanoclone "^0.1.5" +nanoption@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/nanoption/-/nanoption-1.0.1.tgz#b83147b9a1810c1e9e31170ac71993fb8c6bcc73" + integrity sha512-KfUnW+FZa6R5fGO11AT+yRtdXsp2Ta7l23gD1TevgYVRHzRS2fWX9dwPwk9gPFGE4FfCn4N5kUm6SmDAUywEng== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -11718,11 +11816,21 @@ node-fetch@^2.2.0: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.3.0.tgz#1a1d940bbfb916a1d3e0219f037e89e71f8c5fa5" integrity sha512-MOd8pV3fxENbryESLgVIeaGKrdl+uaYhCSSVkjeOb/31/njTpcis5aWfdqgNlHIrKOLRbMnfPINPOML2CIFeXA== +node-fetch@^2.3.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" + integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== + node-forge@^0.7.1: version "0.7.6" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw== +node-forge@^0.8.0: + version "0.8.5" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.8.5.tgz#57906f07614dc72762c84cef442f427c0e1b86ee" + integrity sha512-vFMQIWt+J/7FLNyKouZ9TazT74PRV3wgv9UT4cRjC8BffxFbKXkgIWR42URCPSnHm/QDz6BOlb2Q0U4+VQT67Q== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" From fdaeee0d2ea3344d3187bd39426239245c3b31fc Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Sat, 17 Aug 2019 14:03:43 +0300 Subject: [PATCH 03/11] feat(social): move google config to config on front --- back/src/user/application/social/GoogleValidator.ts | 2 +- front/next.config.js | 3 +++ front/src/ui/auth-widget/google/Google.tsx | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/back/src/user/application/social/GoogleValidator.ts b/back/src/user/application/social/GoogleValidator.ts index b720a1a9..a853c12e 100644 --- a/back/src/user/application/social/GoogleValidator.ts +++ b/back/src/user/application/social/GoogleValidator.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { OAuth2Client } from 'google-auth-library'; import * as deepEqual from 'fast-deep-equal'; +import { OAuth2Client } from 'google-auth-library'; import { Configuration } from '&back/config/Configuration'; import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; diff --git a/front/next.config.js b/front/next.config.js index 42cc8fd4..ebc9aab5 100644 --- a/front/next.config.js +++ b/front/next.config.js @@ -23,6 +23,9 @@ module.exports = withPlugins( publicRuntimeConfig: { backUrl: process.env.BACK_URL || 'http://localhost:3000', backUrlServer: process.env.BACK_URL_SERVER || 'http://localhost:3000', + googleClientId: + process.env.GOOGLE_CLIENT_ID || + '619616345812-bi543g7ojta4uqq4kk1ccp428pik8hp8', }, }, ], diff --git a/front/src/ui/auth-widget/google/Google.tsx b/front/src/ui/auth-widget/google/Google.tsx index e0b9c6f3..70861f84 100644 --- a/front/src/ui/auth-widget/google/Google.tsx +++ b/front/src/ui/auth-widget/google/Google.tsx @@ -1,9 +1,10 @@ +import getConfig from 'next/config'; import Head from 'next/head'; import React, { useEffect, useState } from 'react'; import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; -const googleClientId = '619616345812-bi543g7ojta4uqq4kk1ccp428pik8hp8'; +const { googleClientId } = getConfig().publicRuntimeConfig; interface Props { onLogin: (profile: GoogleProfile) => any; From b4e9b360ae256591261b9b84faaf725f89699a5e Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Sat, 17 Aug 2019 14:20:14 +0300 Subject: [PATCH 04/11] feat(social): add google binding --- back/evolutions/12.sql | 7 +++++++ back/src/user/application/SocialBinder.ts | 6 +++++- back/src/user/domain/User.entity.ts | 7 +++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 back/evolutions/12.sql diff --git a/back/evolutions/12.sql b/back/evolutions/12.sql new file mode 100644 index 00000000..38e2d46f --- /dev/null +++ b/back/evolutions/12.sql @@ -0,0 +1,7 @@ +ALTER TABLE public."user" + ADD "googleId" VARCHAR(255) DEFAULT NULL; + +#DOWN + +ALTER TABLE public."user" + DROP COLUMN "googleId"; diff --git a/back/src/user/application/SocialBinder.ts b/back/src/user/application/SocialBinder.ts index 42eb1e9a..d5396f62 100644 --- a/back/src/user/application/SocialBinder.ts +++ b/back/src/user/application/SocialBinder.ts @@ -5,12 +5,14 @@ import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; import { UserRepository } from '../domain/UserRepository'; import { InvalidSocialRequestException } from './exception/InvalidSocialRequestException'; import { GoogleValidator } from './social/GoogleValidator'; +import { EntitySaver } from '&back/db/EntitySaver'; @Injectable() export class SocialBinder { constructor( private readonly googleValidator: GoogleValidator, private readonly userRepo: UserRepository, + private readonly entitySaver: EntitySaver, ) {} async bindGoogle(login: string, profile: GoogleProfile) { @@ -23,6 +25,8 @@ export class SocialBinder { throw new InvalidSocialRequestException(login, 'Google', profile); } - // TODO: bind + user.attachGoogle(profile.id); + + await this.entitySaver.save(user); } } diff --git a/back/src/user/domain/User.entity.ts b/back/src/user/domain/User.entity.ts index dc1ce021..c42851dd 100644 --- a/back/src/user/domain/User.entity.ts +++ b/back/src/user/domain/User.entity.ts @@ -20,6 +20,9 @@ export class User { @Column() private telegramId: string | undefined; + @Column() + private googleId: string | undefined; + public constructor(login: string) { this.login = login; @@ -43,4 +46,8 @@ export class User { public attachTelegram(telegramId: number): void { this.telegramId = telegramId.toString(); } + + public attachGoogle(googleId: string): void { + this.googleId = googleId; + } } From 73793c09aa29abb04805da221ee017f6fb4ef8ce Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Sat, 17 Aug 2019 14:33:45 +0300 Subject: [PATCH 05/11] refactor(user): make sign in flow more readable --- back/src/user/application/Authenticator.ts | 23 +++------------ back/src/user/application/Registrator.ts | 16 ----------- back/src/user/application/SignInProvider.ts | 28 +++++++++++++++++++ back/src/user/application/SocialBinder.ts | 15 ++++++++++ .../http/controller/AuthController.ts | 5 +++- .../telegram/actions/AuthActions.ts | 11 +++++--- back/src/user/user.module.ts | 2 ++ 7 files changed, 60 insertions(+), 40 deletions(-) create mode 100644 back/src/user/application/SignInProvider.ts diff --git a/back/src/user/application/Authenticator.ts b/back/src/user/application/Authenticator.ts index 12abfbc6..2ac222fc 100644 --- a/back/src/user/application/Authenticator.ts +++ b/back/src/user/application/Authenticator.ts @@ -28,27 +28,12 @@ export class Authenticator { } } - public async signIn(login: string, password: string): Promise { - const user = await this.userRepo.getOne(login); - const passwordValid = await user.isPasswordValid( - password, - this.passwordEncoder, - ); - - if (!passwordValid) { - throw new InvalidCredentialsException(login, password); - } - - const payload = this.createTokenPayload(user); - const token = this.jwt.sign(payload); - - return token; - } - - private createTokenPayload(user: User): TokenPayloadModel { - return { + public async encode(user: User): Promise { + const payload: TokenPayloadModel = { login: user.login, isManager: user.isManager, }; + + return this.jwt.sign(payload); } } diff --git a/back/src/user/application/Registrator.ts b/back/src/user/application/Registrator.ts index e6739a78..663a9e22 100644 --- a/back/src/user/application/Registrator.ts +++ b/back/src/user/application/Registrator.ts @@ -27,20 +27,4 @@ export class Registrator { await this.entitySaver.save(user); } - - public async addTelegramAccount( - login: string, - telegramId: number, - ): Promise { - const attachedUser = await this.userRepo.findOneByTelegram(telegramId); - if (attachedUser.nonEmpty()) { - throw new LoginAlreadyTakenException(login); - } - - const user = await this.userRepo.getOne(login); - - user.attachTelegram(telegramId); - - await this.entitySaver.save(user); - } } diff --git a/back/src/user/application/SignInProvider.ts b/back/src/user/application/SignInProvider.ts new file mode 100644 index 00000000..fafa97d9 --- /dev/null +++ b/back/src/user/application/SignInProvider.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { User } from '../domain/User.entity'; +import { UserRepository } from '../domain/UserRepository'; +import { PasswordEncoder } from '../infrastructure/PasswordEncoder/PasswordEncoder'; +import { InvalidCredentialsException } from './exception/InvalidCredentialsException'; + +@Injectable() +export class SignInProvider { + constructor( + private readonly userRepo: UserRepository, + private readonly passwordEncoder: PasswordEncoder, + ) {} + + async signInByLogin(login: string, password: string): Promise { + const user = await this.userRepo.getOne(login); + + const passwordValid = await user.isPasswordValid( + password, + this.passwordEncoder, + ); + + if (!passwordValid) { + throw new InvalidCredentialsException(login, password); + } + + return user; + } +} diff --git a/back/src/user/application/SocialBinder.ts b/back/src/user/application/SocialBinder.ts index d5396f62..11a866c5 100644 --- a/back/src/user/application/SocialBinder.ts +++ b/back/src/user/application/SocialBinder.ts @@ -6,6 +6,7 @@ import { UserRepository } from '../domain/UserRepository'; import { InvalidSocialRequestException } from './exception/InvalidSocialRequestException'; import { GoogleValidator } from './social/GoogleValidator'; import { EntitySaver } from '&back/db/EntitySaver'; +import { LoginAlreadyTakenException } from './exception/LoginAlreadyTakenException'; @Injectable() export class SocialBinder { @@ -29,4 +30,18 @@ export class SocialBinder { await this.entitySaver.save(user); } + + async bindTelegram(login: string, telegramId: number) { + const attachedUser = await this.userRepo.findOneByTelegram(telegramId); + + if (attachedUser.nonEmpty()) { + throw new LoginAlreadyTakenException(login); + } + + const user = await this.userRepo.getOne(login); + + user.attachTelegram(telegramId); + + await this.entitySaver.save(user); + } } diff --git a/back/src/user/presentation/http/controller/AuthController.ts b/back/src/user/presentation/http/controller/AuthController.ts index d3a7860b..ae1079bb 100644 --- a/back/src/user/presentation/http/controller/AuthController.ts +++ b/back/src/user/presentation/http/controller/AuthController.ts @@ -13,6 +13,7 @@ import { PostNoCreate } from '&back/utils/presentation/http/PostNoCreate'; import { AuthRequest } from '../request/AuthRequest'; import { TokenResponse } from '../response/TokenResponse'; +import { SignInProvider } from '&back/user/application/SignInProvider'; @Controller('user/auth') @ApiUseTags('user') @@ -20,6 +21,7 @@ export class AuthController { public constructor( private readonly registrator: Registrator, private readonly authenticator: Authenticator, + private readonly signInProvider: SignInProvider, ) {} @PostNoCreate('sign-in') @@ -48,7 +50,8 @@ export class AuthController { login: string, password: string, ): Promise { - const token = await this.authenticator.signIn(login, password); + const user = await this.signInProvider.signInByLogin(login, password); + const token = await this.authenticator.encode(user); return { token, diff --git a/back/src/user/presentation/telegram/actions/AuthActions.ts b/back/src/user/presentation/telegram/actions/AuthActions.ts index 835ca216..0fde50a5 100644 --- a/back/src/user/presentation/telegram/actions/AuthActions.ts +++ b/back/src/user/presentation/telegram/actions/AuthActions.ts @@ -2,17 +2,19 @@ import { Injectable } from '@nestjs/common'; import { TelegramActionHandler, Context, PipeContext } from 'nest-telegram'; import { Authenticator } from '&back/user/application/Authenticator'; -import { Registrator } from '&back/user/application/Registrator'; import { Templating } from '&back/utils/infrastructure/Templating/Templating'; import { IsKnownUser } from '../transformer/IsKnownUser'; +import { SignInProvider } from '&back/user/application/SignInProvider'; +import { SocialBinder } from '&back/user/application/SocialBinder'; @Injectable() export class AuthActions { constructor( private readonly authenticator: Authenticator, - private readonly registrator: Registrator, + private readonly signInProvider: SignInProvider, private readonly templating: Templating, + private readonly socialBinder: SocialBinder, ) {} @TelegramActionHandler({ onStart: true }) @@ -31,8 +33,9 @@ export class AuthActions { public async auth(ctx: Context) { const [_, login, password] = ctx.message.text.split(' '); - await this.authenticator.signIn(login, password); - await this.registrator.addTelegramAccount(login, ctx.from.id); + const user = await this.signInProvider.signInByLogin(login, password); + await this.authenticator.encode(user); + await this.socialBinder.bindTelegram(login, ctx.from.id); await ctx.reply('Success'); } } diff --git a/back/src/user/user.module.ts b/back/src/user/user.module.ts index cebb7bde..5913e444 100644 --- a/back/src/user/user.module.ts +++ b/back/src/user/user.module.ts @@ -27,6 +27,7 @@ import { AuthActions } from './presentation/telegram/actions/AuthActions'; import { InvalidCredentialsCatcher } from './presentation/telegram/catcher/InvalidCredentialsCatcher'; import { CurrentSender } from './presentation/telegram/transformer/CurrentSender'; import { IsKnownUser } from './presentation/telegram/transformer/IsKnownUser'; +import { SignInProvider } from './application/SignInProvider'; @Module({ imports: [ @@ -48,6 +49,7 @@ import { IsKnownUser } from './presentation/telegram/transformer/IsKnownUser'; GoogleValidator, LoginAlreadyTakenFilter.provider(), InvalidCredentialsFilter.provider(), + SignInProvider, Authenticator, Registrator, ProfileEditor, From 728cb386240062e0413122a71e572aa9519fbd30 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Sat, 17 Aug 2019 15:13:47 +0300 Subject: [PATCH 06/11] feat(social): add google widget to login page --- back/src/user/application/SignInProvider.ts | 31 +++++++++++++++++++ back/src/user/application/SocialBinder.ts | 4 +-- back/src/user/domain/UserRepository.ts | 11 +++++++ .../http/controller/AuthController.ts | 28 ++++++++++++----- .../telegram/actions/AuthActions.ts | 4 +-- back/src/user/user.module.ts | 2 +- front/src/domain/user/actions/signInGoogle.ts | 20 ++++++++++++ front/src/features/landing/Landing.css | 9 ++++-- front/src/features/landing/Landing.tsx | 2 ++ .../landing/features/social/Social.tsx | 26 ++++++++++++++++ .../landing/features/social/index.tsx | 1 + 11 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 front/src/domain/user/actions/signInGoogle.ts create mode 100644 front/src/features/landing/features/social/Social.tsx create mode 100644 front/src/features/landing/features/social/index.tsx diff --git a/back/src/user/application/SignInProvider.ts b/back/src/user/application/SignInProvider.ts index fafa97d9..2e704944 100644 --- a/back/src/user/application/SignInProvider.ts +++ b/back/src/user/application/SignInProvider.ts @@ -1,14 +1,22 @@ import { Injectable } from '@nestjs/common'; + +import { EntitySaver } from '&back/db/EntitySaver'; +import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; + import { User } from '../domain/User.entity'; import { UserRepository } from '../domain/UserRepository'; import { PasswordEncoder } from '../infrastructure/PasswordEncoder/PasswordEncoder'; import { InvalidCredentialsException } from './exception/InvalidCredentialsException'; +import { InvalidSocialRequestException } from './exception/InvalidSocialRequestException'; +import { GoogleValidator } from './social/GoogleValidator'; @Injectable() export class SignInProvider { constructor( private readonly userRepo: UserRepository, private readonly passwordEncoder: PasswordEncoder, + private readonly googleValidator: GoogleValidator, + private readonly entitySaver: EntitySaver, ) {} async signInByLogin(login: string, password: string): Promise { @@ -25,4 +33,27 @@ export class SignInProvider { return user; } + + async signInByGoogle(profile: GoogleProfile): Promise { + const [valid, optionalUser] = await Promise.all([ + this.googleValidator.isValid(profile), + this.userRepo.findOneByGoogle(profile.id), + ]); + + if (!valid) { + throw new InvalidSocialRequestException(profile.email, 'Google', profile); + } + + // okay, user already exist, just sign-in + if (optionalUser.nonEmpty()) { + return optionalUser.get(); + } + + const user = new User(`google-${profile.id}`); + user.attachGoogle(profile.id); + + await this.entitySaver.save(user); + + return user; + } } diff --git a/back/src/user/application/SocialBinder.ts b/back/src/user/application/SocialBinder.ts index 11a866c5..8faec0cc 100644 --- a/back/src/user/application/SocialBinder.ts +++ b/back/src/user/application/SocialBinder.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; +import { EntitySaver } from '&back/db/EntitySaver'; import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; import { UserRepository } from '../domain/UserRepository'; import { InvalidSocialRequestException } from './exception/InvalidSocialRequestException'; -import { GoogleValidator } from './social/GoogleValidator'; -import { EntitySaver } from '&back/db/EntitySaver'; import { LoginAlreadyTakenException } from './exception/LoginAlreadyTakenException'; +import { GoogleValidator } from './social/GoogleValidator'; @Injectable() export class SocialBinder { diff --git a/back/src/user/domain/UserRepository.ts b/back/src/user/domain/UserRepository.ts index 5e018513..7ee5ab31 100644 --- a/back/src/user/domain/UserRepository.ts +++ b/back/src/user/domain/UserRepository.ts @@ -56,6 +56,17 @@ class UserRepo { return Option.of(user); } + public async findOneByGoogle(googleId: string): Promise> { + const user = await this.userRepo + .createQueryBuilder() + .where({ + googleId, + }) + .getOne(); + + return Option.of(user); + } + public async getDefaultCurrency(login: string): Promise { const user = await this.getOne(login); diff --git a/back/src/user/presentation/http/controller/AuthController.ts b/back/src/user/presentation/http/controller/AuthController.ts index ae1079bb..e8ab7a43 100644 --- a/back/src/user/presentation/http/controller/AuthController.ts +++ b/back/src/user/presentation/http/controller/AuthController.ts @@ -9,11 +9,13 @@ import { import { Authenticator } from '&back/user/application/Authenticator'; import { Registrator } from '&back/user/application/Registrator'; +import { SignInProvider } from '&back/user/application/SignInProvider'; +import { User } from '&back/user/domain/User.entity'; import { PostNoCreate } from '&back/utils/presentation/http/PostNoCreate'; import { AuthRequest } from '../request/AuthRequest'; +import { GoogleBindRequest } from '../request/GoogleBindRequest'; import { TokenResponse } from '../response/TokenResponse'; -import { SignInProvider } from '&back/user/application/SignInProvider'; @Controller('user/auth') @ApiUseTags('user') @@ -31,7 +33,9 @@ export class AuthController { public async signIn(@Body() request: AuthRequest): Promise { const { email, password } = request; - return this.createResponseByCredentials(email, password); + const user = await this.signInProvider.signInByLogin(email, password); + + return this.createResponse(user); } @Post('sign-up') @@ -43,14 +47,24 @@ export class AuthController { await this.registrator.signUp(email, password); - return this.createResponseByCredentials(email, password); + const user = await this.signInProvider.signInByLogin(email, password); + + return this.createResponse(user); } - private async createResponseByCredentials( - login: string, - password: string, + @Post('google') + @ApiOperation({ title: 'Sign-up (or sign-in) by google account' }) + @ApiCreatedResponse({ description: 'Success', type: TokenResponse }) + @ApiBadRequestResponse({ description: 'Invalid Google payload' }) + async authByGoogle( + @Body() request: GoogleBindRequest, ): Promise { - const user = await this.signInProvider.signInByLogin(login, password); + const user = await this.signInProvider.signInByGoogle(request); + + return this.createResponse(user); + } + + private async createResponse(user: User): Promise { const token = await this.authenticator.encode(user); return { diff --git a/back/src/user/presentation/telegram/actions/AuthActions.ts b/back/src/user/presentation/telegram/actions/AuthActions.ts index 0fde50a5..05ff3fef 100644 --- a/back/src/user/presentation/telegram/actions/AuthActions.ts +++ b/back/src/user/presentation/telegram/actions/AuthActions.ts @@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common'; import { TelegramActionHandler, Context, PipeContext } from 'nest-telegram'; import { Authenticator } from '&back/user/application/Authenticator'; +import { SignInProvider } from '&back/user/application/SignInProvider'; +import { SocialBinder } from '&back/user/application/SocialBinder'; import { Templating } from '&back/utils/infrastructure/Templating/Templating'; import { IsKnownUser } from '../transformer/IsKnownUser'; -import { SignInProvider } from '&back/user/application/SignInProvider'; -import { SocialBinder } from '&back/user/application/SocialBinder'; @Injectable() export class AuthActions { diff --git a/back/src/user/user.module.ts b/back/src/user/user.module.ts index 5913e444..ca2d268d 100644 --- a/back/src/user/user.module.ts +++ b/back/src/user/user.module.ts @@ -9,6 +9,7 @@ import { UtilsModule } from '&back/utils/utils.module'; import { Authenticator } from './application/Authenticator'; import { ProfileEditor } from './application/ProfileEditor'; import { Registrator } from './application/Registrator'; +import { SignInProvider } from './application/SignInProvider'; import { GoogleValidator } from './application/social/GoogleValidator'; import { SocialBinder } from './application/SocialBinder'; import { User } from './domain/User.entity'; @@ -27,7 +28,6 @@ import { AuthActions } from './presentation/telegram/actions/AuthActions'; import { InvalidCredentialsCatcher } from './presentation/telegram/catcher/InvalidCredentialsCatcher'; import { CurrentSender } from './presentation/telegram/transformer/CurrentSender'; import { IsKnownUser } from './presentation/telegram/transformer/IsKnownUser'; -import { SignInProvider } from './application/SignInProvider'; @Module({ imports: [ diff --git a/front/src/domain/user/actions/signInGoogle.ts b/front/src/domain/user/actions/signInGoogle.ts new file mode 100644 index 00000000..fd132780 --- /dev/null +++ b/front/src/domain/user/actions/signInGoogle.ts @@ -0,0 +1,20 @@ +import { fetchOrFail } from '&front/domain/store'; +import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; +import { TokenModel } from '&shared/models/user/TokenModel'; + +import { setCookie } from '../helpers/setCookie'; +import { actions as dataActions } from '../reducer/data'; +import { actions as signInActions } from '../reducer/signIn'; + +const { setToken } = dataActions; + +export const signInGoogle = (profile: GoogleProfile) => + fetchOrFail(signInActions, async (dispatch, getApi) => { + const { token } = await getApi() + .client.post('user/auth/google', profile) + .then(response => response.data as TokenModel); + + setCookie(token); + + dispatch(setToken(token)); + }); diff --git a/front/src/features/landing/Landing.css b/front/src/features/landing/Landing.css index 0b6b1ea2..f51b8059 100644 --- a/front/src/features/landing/Landing.css +++ b/front/src/features/landing/Landing.css @@ -2,8 +2,8 @@ display: grid; grid-template: - 'message message' 100px - 'sign-in sign-up' 1fr; + 'message message message' 100px + 'sign-in social sign-up' 1fr; align-items: center; justify-content: space-eventaly; @@ -22,6 +22,7 @@ @media (max-width: 768px) { grid-template: 'message' + 'social' 'sign-in' 'sign-up'; } @@ -39,3 +40,7 @@ .signUp { grid-area: sign-up; } + +.social { + grid-area: social; +} diff --git a/front/src/features/landing/Landing.tsx b/front/src/features/landing/Landing.tsx index 907e2169..fe7e4882 100644 --- a/front/src/features/landing/Landing.tsx +++ b/front/src/features/landing/Landing.tsx @@ -5,6 +5,7 @@ import { ForbiddenMessage } from './components/fornidden-message'; import { HelloMessage } from './components/hello-message'; import { SignIn } from './features/sign-in'; import { SignUp } from './features/sign-up'; +import { Social } from './features/social'; import * as styles from './Landing.css'; interface Props { @@ -23,6 +24,7 @@ export const Landing = ({ forbidden = false }: Props) => { )} + ); }; diff --git a/front/src/features/landing/features/social/Social.tsx b/front/src/features/landing/features/social/Social.tsx new file mode 100644 index 00000000..be33a207 --- /dev/null +++ b/front/src/features/landing/features/social/Social.tsx @@ -0,0 +1,26 @@ +import React, { useCallback } from 'react'; + +import { useThunk } from '&front/domain/store'; +import { signInGoogle } from '&front/domain/user/actions/signInGoogle'; +import { pushRoute } from '&front/features/routing'; +import { Google } from '&front/ui/auth-widget/google'; +import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; + +interface Props { + className: string; +} + +export const Social = ({ className }: Props) => { + const dispatch = useThunk(); + + const handleGoogle = useCallback(async (profile: GoogleProfile) => { + await dispatch(signInGoogle(profile)); + await pushRoute('/app'); + }, []); + + return ( +
+ +
+ ); +}; diff --git a/front/src/features/landing/features/social/index.tsx b/front/src/features/landing/features/social/index.tsx new file mode 100644 index 00000000..cc092c7c --- /dev/null +++ b/front/src/features/landing/features/social/index.tsx @@ -0,0 +1 @@ +export { Social } from './Social'; From 743562fa3debf0b50b8d88d79f9029c5c0df75b3 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Sun, 18 Aug 2019 15:41:54 +0300 Subject: [PATCH 07/11] feat(social): finalize google login --- front/next.config.js | 3 +- front/package.json | 1 + front/src/domain/api/Api.ts | 6 +-- front/src/ui/auth-widget/google/Google.tsx | 43 ++++++++++------------ yarn.lock | 8 ++++ 5 files changed, 32 insertions(+), 29 deletions(-) diff --git a/front/next.config.js b/front/next.config.js index ebc9aab5..a1ba0c54 100644 --- a/front/next.config.js +++ b/front/next.config.js @@ -22,10 +22,9 @@ module.exports = withPlugins( { publicRuntimeConfig: { backUrl: process.env.BACK_URL || 'http://localhost:3000', - backUrlServer: process.env.BACK_URL_SERVER || 'http://localhost:3000', googleClientId: process.env.GOOGLE_CLIENT_ID || - '619616345812-bi543g7ojta4uqq4kk1ccp428pik8hp8', + '619616345812-bi543g7ojta4uqq4kk1ccp428pik8hp8.apps.googleusercontent.com', }, }, ], diff --git a/front/package.json b/front/package.json index bba2caac..c1f9ebe0 100644 --- a/front/package.json +++ b/front/package.json @@ -34,6 +34,7 @@ "react-dom": "^16.8.1", "react-final-form": "^4.0.2", "react-final-form-hooks": "^1.0.0", + "react-google-login": "^5.0.5", "react-redux": "^6.0.0", "redux": "^4.0.1", "redux-clear": "^v1.1.2", diff --git a/front/src/domain/api/Api.ts b/front/src/domain/api/Api.ts index 223b9f22..c9361b67 100644 --- a/front/src/domain/api/Api.ts +++ b/front/src/domain/api/Api.ts @@ -5,7 +5,7 @@ import { Option } from 'tsoption'; import { canUseDOM } from '&front/helpers/canUseDOM'; const { publicRuntimeConfig } = getConfig(); -const { backUrl, backUrlServer } = publicRuntimeConfig; +const { backUrl } = publicRuntimeConfig; export class Api { public get client() { @@ -19,10 +19,8 @@ export class Api { ? { Authorization: `Bearer ${token.get()}` } : {}; - const realBackUrl = canUseDOM() ? backUrl : backUrlServer; - this.axios = axios.create({ - baseURL: realBackUrl, + baseURL: backUrl, headers: authHeaders, }); } diff --git a/front/src/ui/auth-widget/google/Google.tsx b/front/src/ui/auth-widget/google/Google.tsx index 70861f84..0779ebdd 100644 --- a/front/src/ui/auth-widget/google/Google.tsx +++ b/front/src/ui/auth-widget/google/Google.tsx @@ -1,7 +1,8 @@ import getConfig from 'next/config'; -import Head from 'next/head'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback } from 'react'; +import { GoogleLogin } from 'react-google-login'; +import { useNotifyAlert } from '&front/ui/hooks/useNotifyAlert'; import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; const { googleClientId } = getConfig().publicRuntimeConfig; @@ -11,10 +12,10 @@ interface Props { } export const Google = ({ onLogin }: Props) => { - const [isClient, setClient] = useState(false); + const showNotify = useNotifyAlert(); - useEffect(() => { - (window as any).onSignIn = (googleUser: any) => { + const handleSuccess = useCallback( + (googleUser: any) => { const rawProfile = googleUser.getBasicProfile(); const profile: GoogleProfile = { @@ -26,26 +27,22 @@ export const Google = ({ onLogin }: Props) => { }; onLogin(profile); - }; + }, + [onLogin], + ); - setClient(true); - }, []); + const handleError = useCallback( + () => showNotify('Что-то пошло не так, попробуйте еще раз'), + [showNotify], + ); return ( - <> - - - - - - {isClient &&
} - + ); }; diff --git a/yarn.lock b/yarn.lock index 1243aaeb..8632f508 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14939,6 +14939,14 @@ react-final-form@^4.0.2: dependencies: "@babel/runtime" "^7.1.2" +react-google-login@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/react-google-login/-/react-google-login-5.0.5.tgz#b327a0ef1d6f466088c1a29dea0a6c29978158dc" + integrity sha512-MiX8ftDY8fqJbMNBsLWUtj3i65Z96MIB4dOYtZcxyqjOp7vkgeL/vsUdu/L0BgNNra2m4zyUD01dITH/19t1uA== + dependencies: + "@types/react" "*" + prop-types "^15.6.0" + react-i18next@9.0.10: version "9.0.10" resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-9.0.10.tgz#ba596b98e8dd06dbb805cf720147459ad55a3ada" From ba1b8e66a6b98bc2d614ba94e717c4dc59c79957 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Sun, 18 Aug 2019 16:51:00 +0300 Subject: [PATCH 08/11] feat(social): add email saving --- back/evolutions/12.sql | 8 ++++++-- back/package.json | 2 +- back/src/user/application/Registrator.ts | 1 + back/src/user/domain/User.entity.ts | 3 +++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/back/evolutions/12.sql b/back/evolutions/12.sql index 38e2d46f..cca7747f 100644 --- a/back/evolutions/12.sql +++ b/back/evolutions/12.sql @@ -1,7 +1,11 @@ ALTER TABLE public."user" - ADD "googleId" VARCHAR(255) DEFAULT NULL; + ADD "googleId" VARCHAR(255) DEFAULT NULL, + ADD "email" VARCHAR(255) DEFAULT NULL; + +UPDATE public."user" SET email=login WHERE email IS NULL; #DOWN ALTER TABLE public."user" - DROP COLUMN "googleId"; + DROP COLUMN "googleId", + DROP COLUMN "email"; diff --git a/back/package.json b/back/package.json index 39d0c25f..a9053370 100644 --- a/back/package.json +++ b/back/package.json @@ -7,7 +7,7 @@ "prestart:back:prod": "tsc && cp -a ./templates ../dist/back/back", "start:back:prod": "pm2 start ./pm2.config.js", "evolutions:init": "evolutions --init", - "evolutions:run": "evolutions" + "evolutions:run": "evolutions -y" }, "dependencies": { "@breadhead/detil-ts": "^1.0.1", diff --git a/back/src/user/application/Registrator.ts b/back/src/user/application/Registrator.ts index 663a9e22..a38f6d96 100644 --- a/back/src/user/application/Registrator.ts +++ b/back/src/user/application/Registrator.ts @@ -24,6 +24,7 @@ export class Registrator { const user = new User(login); await user.changePassword(password, this.passwordEncoder); + user.email = login; await this.entitySaver.save(user); } diff --git a/back/src/user/domain/User.entity.ts b/back/src/user/domain/User.entity.ts index c42851dd..95a534a1 100644 --- a/back/src/user/domain/User.entity.ts +++ b/back/src/user/domain/User.entity.ts @@ -14,6 +14,9 @@ export class User { @Column() public readonly isManager: boolean = false; + @Column() + public email: string | undefined; + @Column() private password: string | undefined; From 4c98c65e09ba31e3d5f436b0fdcad06e843288c0 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Sun, 18 Aug 2019 16:53:05 +0300 Subject: [PATCH 09/11] fix: fix typo --- front/src/features/landing/Landing.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/src/features/landing/Landing.tsx b/front/src/features/landing/Landing.tsx index fe7e4882..669de968 100644 --- a/front/src/features/landing/Landing.tsx +++ b/front/src/features/landing/Landing.tsx @@ -24,7 +24,7 @@ export const Landing = ({ forbidden = false }: Props) => { )} - + ); }; From 4e82a0c429947bea7559ed25db3a6a34417587a2 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Sun, 18 Aug 2019 17:04:17 +0300 Subject: [PATCH 10/11] feat(social): handle success binding --- front/src/domain/user/actions/bindGoogle.ts | 1 - front/src/features/profile/Profile.tsx | 12 ++++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/front/src/domain/user/actions/bindGoogle.ts b/front/src/domain/user/actions/bindGoogle.ts index 78ba7f60..098c22a4 100644 --- a/front/src/domain/user/actions/bindGoogle.ts +++ b/front/src/domain/user/actions/bindGoogle.ts @@ -3,6 +3,5 @@ import { GoogleProfile } from '&shared/models/user/external/GoogleProfile'; export const bindGoogle = (profile: GoogleProfile) => fetchOrFail(undefined, async (_, getApi) => { - // TODO: react on response await getApi().client.post('/user/bind/google', profile); }); diff --git a/front/src/features/profile/Profile.tsx b/front/src/features/profile/Profile.tsx index f9acb3c3..a526e747 100644 --- a/front/src/features/profile/Profile.tsx +++ b/front/src/features/profile/Profile.tsx @@ -38,12 +38,16 @@ export const Profile = () => { const [onMonday, setOnMonday] = useState(weekStartsOnMonday); const saved = useCallback(() => notify('Saved'), [notify]); - - const handleGoogleLogin = useCallback( - (profile: GoogleProfile) => dispatch(bindGoogle(profile)), - [], + const bound = useCallback( + (social: string) => notify(`Аккаунт "${social}" прикреплен`), + [notify], ); + const handleGoogleLogin = useCallback(async (profile: GoogleProfile) => { + await dispatch(bindGoogle(profile)); + bound('google'); + }, []); + useEffect(() => { if (currency !== defaultCurrency) { dispatch(setDefaultCurrency(currency)).then(saved); From 3c7c1e119525444e38730a2a2a139cceb90acc45 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Sun, 18 Aug 2019 17:04:53 +0300 Subject: [PATCH 11/11] refactor: remove unused var --- front/src/domain/api/Api.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/front/src/domain/api/Api.ts b/front/src/domain/api/Api.ts index c9361b67..a34603f8 100644 --- a/front/src/domain/api/Api.ts +++ b/front/src/domain/api/Api.ts @@ -2,8 +2,6 @@ import axios, { AxiosInstance } from 'axios'; import getConfig from 'next/config'; import { Option } from 'tsoption'; -import { canUseDOM } from '&front/helpers/canUseDOM'; - const { publicRuntimeConfig } = getConfig(); const { backUrl } = publicRuntimeConfig;