Skip to content

Commit

Permalink
feat Added architecture for login with github (#184)
Browse files Browse the repository at this point in the history
  • Loading branch information
chavda-bhavik authored Feb 6, 2023
2 parents efcb50b + 2e8bbc7 commit 0514254
Show file tree
Hide file tree
Showing 25 changed files with 691 additions and 19 deletions.
16 changes: 16 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,46 @@
"@impler/shared": "^0.1.12",
"@nestjs/common": "^9.1.2",
"@nestjs/core": "^9.1.2",
"@nestjs/jwt": "^10.0.1",
"@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.1.2",
"@nestjs/swagger": "^6.1.2",
"@nestjs/terminus": "^9.1.3",
"@sentry/node": "^7.19.0",
"@types/jsonwebtoken": "^9.0.1",
"ajv": "^8.11.0",
"ajv-formats": "^2.1.1",
"ajv-keywords": "^5.1.0",
"body-parser": "^1.20.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"dotenv": "^16.0.2",
"envalid": "^7.3.1",
"fast-csv": "^4.3.6",
"jsonwebtoken": "^9.0.0",
"passport": "^0.6.0",
"passport-github2": "^0.1.12",
"passport-jwt": "^4.0.1",
"passport-oauth2": "^1.6.1",
"rimraf": "^3.0.2",
"rxjs": "^7.8.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@nestjs/cli": "^9.1.5",
"@types/body-parser": "^1.19.2",
"@types/chai": "^4.3.4",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.14",
"@types/mocha": "^10.0.0",
"@types/multer": "^1.4.7",
"@types/node": "^18.7.18",
"@types/passport": "^1.0.11",
"@types/passport-github2": "^1.2.5",
"@types/passport-jwt": "^3.0.8",
"@types/passport-oauth2": "^1.4.11",
"chai": "^4.3.7",
"mocha": "^10.1.0",
"nodemon": "^2.0.20",
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/.env.development
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
NODE_ENV=local
PORT=3000
FRONT_BASE_URL=http://localhost:4200
RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672

# Database
Expand All @@ -13,8 +12,16 @@ S3_BUCKET_NAME=impler
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

# URLs
FRONT_BASE_URL=http://localhost:3500
WEB_BASE_URL=http://localhost:4200

# Security
ACCESS_KEY=
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
GITHUB_OAUTH_REDIRECT=
CLIENT_SUCCESS_AUTH_REDIRECT=

# Analytics
SENTRY_DSN=
9 changes: 8 additions & 1 deletion apps/api/src/.env.production
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
NODE_ENV=local
PORT=3000
FRONT_BASE_URL=http://localhost:4200
RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672

# Database
Expand All @@ -13,8 +12,16 @@ S3_BUCKET_NAME=impler
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

# URLs
FRONT_BASE_URL=http://localhost:3500
WEB_BASE_URL=http://localhost:4200

# Security
ACCESS_KEY=
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
GITHUB_OAUTH_REDIRECT=
CLIENT_SUCCESS_AUTH_REDIRECT=

# Analytics
SENTRY_DSN=
9 changes: 8 additions & 1 deletion apps/api/src/.env.test
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
NODE_ENV=local
PORT=3000
FRONT_BASE_URL=http://localhost:4200
RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672

# Database
Expand All @@ -13,8 +12,16 @@ S3_BUCKET_NAME=impler
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

# URLs
FRONT_BASE_URL=http://localhost:3500
WEB_BASE_URL=http://localhost:4200

# Security
ACCESS_KEY=
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
GITHUB_OAUTH_REDIRECT=
CLIENT_SUCCESS_AUTH_REDIRECT=

# Analytics
SENTRY_DSN=
9 changes: 8 additions & 1 deletion apps/api/src/.example.env
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
NODE_ENV=local
PORT=3000
FRONT_BASE_URL=http://localhost:4200
RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672

# Database
Expand All @@ -11,5 +10,13 @@ S3_LOCAL_STACK=http://localhost:4566
S3_REGION=us-east-1
S3_BUCKET_NAME=impler

# URLs
FRONT_BASE_URL=http://localhost:4200
WEB_BASE_URL=http://localhost:3500

# Security
ACCESS_KEY=
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
GITHUB_OAUTH_REDIRECT=
CLIENT_SUCCESS_AUTH_REDIRECT=
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { MappingModule } from './app/mapping/mapping.module';
import { ReviewModule } from './app/review/review.module';
import { CommonModule } from './app/common/common.module';
import { HealthModule } from 'app/health/health.module';
import { AuthModule } from './app/auth/auth.module';

const modules: Array<Type | DynamicModule | Promise<DynamicModule> | ForwardReference> = [
ProjectModule,
Expand All @@ -21,6 +22,7 @@ const modules: Array<Type | DynamicModule | Promise<DynamicModule> | ForwardRefe
ReviewModule,
CommonModule,
HealthModule,
AuthModule,
];

const providers = [];
Expand Down
51 changes: 51 additions & 0 deletions apps/api/src/app/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { ApiTags, ApiExcludeController } from '@nestjs/swagger';
import { ClassSerializerInterceptor, Controller, Get, Res, UseGuards, UseInterceptors } from '@nestjs/common';
import { IJwtPayload } from '@impler/shared';
import { IStrategyResponse } from '@shared/types/auth.types';
import { UserSession } from '@shared/framework/user.decorator';
import { ApiException } from '@shared/exceptions/api.exception';
import { StrategyUser } from './decorators/strategy-user.decorator';
import { CONSTANTS, COOKIE_CONFIG } from '@shared/constants';

@ApiTags('Auth')
@Controller('/auth')
@ApiExcludeController()
@UseInterceptors(ClassSerializerInterceptor)
export class AuthController {
@Get('/github')
githubAuth() {
if (!process.env.GITHUB_OAUTH_CLIENT_ID || !process.env.GITHUB_OAUTH_CLIENT_SECRET) {
throw new ApiException(
'GitHub auth is not configured, please provide GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET as env variables'
);
}

return {
success: true,
};
}

@Get('/github/callback')
@UseGuards(AuthGuard('github'))
async githubCallback(@StrategyUser() strategyUser: IStrategyResponse, @Res() response: Response) {
if (!strategyUser || !strategyUser.token) {
return response.redirect(`${process.env.CLIENT_SUCCESS_AUTH_REDIRECT}?error=AuthenticationError`);
}

let url = process.env.CLIENT_SUCCESS_AUTH_REDIRECT;
if (strategyUser.userCreated && strategyUser.showAddProject) {
url += `?showAddProject=true`;
}

response.cookie(CONSTANTS.AUTH_COOKIE_NAME, strategyUser.token, COOKIE_CONFIG);

return response.send(url);
}

@Get('/user')
async user(@UserSession() user: IJwtPayload) {
return user;
}
}
51 changes: 51 additions & 0 deletions apps/api/src/app/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { MiddlewareConsumer, Module, NestModule, Provider, RequestMethod } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import * as passport from 'passport';
import { CONSTANTS } from '@shared/constants';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './services/auth.service';
import { SharedModule } from '../shared/shared.module';
import { GitHubStrategy } from './services/passport/github.strategy';
import { JwtStrategy } from './services/passport/jwt.strategy';

const AUTH_STRATEGIES: Provider[] = [JwtStrategy];

if (process.env.GITHUB_OAUTH_CLIENT_ID) {
AUTH_STRATEGIES.push(GitHubStrategy);
}

@Module({
imports: [
SharedModule,
PassportModule.register({
defaultStrategy: 'jwt',
}),
JwtModule.register({
secretOrKeyProvider: () => process.env.JWT_SECRET as string,
signOptions: {
expiresIn: CONSTANTS.maxAge,
},
}),
],
controllers: [AuthController],
providers: [AuthService, ...AUTH_STRATEGIES],
exports: [],
})
export class AuthModule implements NestModule {
public configure(consumer: MiddlewareConsumer) {
if (process.env.GITHUB_OAUTH_CLIENT_ID) {
consumer
.apply(
passport.authenticate('github', {
session: false,
scope: ['user:email'],
})
)
.forRoutes({
path: '/auth/github',
method: RequestMethod.GET,
});
}
}
}
8 changes: 8 additions & 0 deletions apps/api/src/app/auth/decorators/strategy-user.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { IStrategyResponse } from '@shared/types/auth.types';

export const StrategyUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();

return request.user as IStrategyResponse;
});
110 changes: 110 additions & 0 deletions apps/api/src/app/auth/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { JwtService } from '@nestjs/jwt';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserEntity, UserRepository, MemberRepository, MemberEntity } from '@impler/dal';
import { IJwtPayload, MemberStatusEnum } from '@impler/shared';
import { UserNotFoundException } from '@shared/exceptions/user-not-found.exception';
import { IAuthenticationData, IStrategyResponse } from '@shared/types/auth.types';

@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private userRepository: UserRepository,
private memberRepository: MemberRepository
) {}

async authenticate({ profile, provider, _invitationId }: IAuthenticationData): Promise<IStrategyResponse> {
let showAddProject = false;
let userCreated = false;
// get or create the user
let user = await this.userRepository.findOne({ email: profile.email });
if (!user) {
const userObj: Partial<UserEntity> = {
email: profile.email,
firstName: profile.firstName,
lastName: profile.lastName,
profilePicture: profile.avatar_url,
...(provider ? { tokens: [provider] } : {}),
};
user = await this.userRepository.create(userObj);
userCreated = true;
}
if (!user) {
throw new UserNotFoundException();
}

// update member or get member
const member: MemberEntity = await this.memberRepository.findOne({
$or: [{ _id: _invitationId }, { 'invite.email': profile.email }],
});

if (!member) {
// invitationId is not valid
showAddProject = true;
} else if (userCreated && member?._id) {
// accept invitation or add user to project only first time
await this.memberRepository.findOneAndUpdate(
{ _id: member._id },
{
_userId: user._id,
'invite.answerDate': new Date(),
memberStatus: MemberStatusEnum.ACTIVE,
}
);
}

return {
user,
userCreated,
showAddProject,
token: await this.getSignedToken(user, member._projectId, member.role),
};
}

async refreshToken(userId: string) {
const user = await this.getUser({ _id: userId });
if (!user) throw new UnauthorizedException('User not found');

return this.getSignedToken(user);
}

async getSignedToken(user: UserEntity, _projectId?: string, role?: string): Promise<string> {
return (
`Bearer ` +
this.jwtService.sign(
{
_id: user._id,
_projectId,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
profilePicture: user.profilePicture,
role,
},
{
expiresIn: '30 days',
issuer: 'impler',
}
)
);
}

async validateUser(payload: IJwtPayload): Promise<UserEntity> {
const user = await this.getUser({ _id: payload._id });
if (!user) throw new UnauthorizedException('User not found');

return user;
}

async decodeJwt<T>(token: string) {
return this.jwtService.decode(token) as T;
}

async verifyJwt(jwt: string) {
return this.jwtService.verify(jwt);
}

private async getUser({ _id }: { _id: string }) {
return await this.userRepository.findById(_id);
}
}
Loading

0 comments on commit 0514254

Please sign in to comment.