Skip to content
This repository has been archived by the owner on May 11, 2021. It is now read-only.

Commit

Permalink
feat(social): add validating google auth payload
Browse files Browse the repository at this point in the history
  • Loading branch information
igorkamyshev committed Aug 17, 2019
1 parent c620e6a commit cb5136e
Show file tree
Hide file tree
Showing 13 changed files with 287 additions and 14 deletions.
5 changes: 4 additions & 1 deletion back/.env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ PRODUCTION_READY=1

MANNY_API_KEY=Secret
TELEGRAM_BOT_TOKEN=Secret
API_PUBLIC_URL=https://api.checkmoney.space
API_PUBLIC_URL=https://api.checkmoney.space

GOOGLE_CLIENT_ID=Secret
GOOGLE_CLIENT_SECRET=Secret
4 changes: 3 additions & 1 deletion back/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions back/src/user/application/SocialBinder.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
45 changes: 45 additions & 0 deletions back/src/user/application/social/GoogleValidator.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
}
}
33 changes: 33 additions & 0 deletions back/src/user/presentation/http/controller/SocialController.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await this.binder.bindGoogle(login, request);
}
}
24 changes: 24 additions & 0 deletions back/src/user/presentation/http/request/GoogleBindRequest.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 6 additions & 1 deletion back/src/user/user.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ 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';
import { BcryptPasswordEncoder } from './infrastructure/PasswordEncoder/BcryptPasswordEncoder';
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';
Expand All @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions front/src/domain/user/actions/bindGoogle.ts
Original file line number Diff line number Diff line change
@@ -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);
});
11 changes: 9 additions & 2 deletions front/src/features/profile/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -78,7 +85,7 @@ export const Profile = () => {
</Card>

<Card title="Logins">
<Google onLogin={console.log} />
<Google onLogin={handleGoogleLogin} />
</Card>
</section>
</Container>
Expand Down
4 changes: 2 additions & 2 deletions front/src/ui/auth-widget/google/Google.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions shared/models/user/external/GoogleProfile.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface GoogleProfile {
readonly id: string;
readonly name: string;
readonly token: string;
readonly photo?: string;
readonly email?: string;
}
Loading

0 comments on commit cb5136e

Please sign in to comment.