Skip to content

Commit

Permalink
Feature/#49 - Oauth 로그인 시 사용자 정보가 없으면 자동 회원가입 (#150)
Browse files Browse the repository at this point in the history
  • Loading branch information
xjfcnfw3 authored Nov 12, 2024
2 parents e544e96 + 20afb94 commit 53e7fc2
Show file tree
Hide file tree
Showing 10 changed files with 178 additions and 35 deletions.
9 changes: 6 additions & 3 deletions packages/backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { GoogleAuthController } from '@/auth/googleAuth.controller';
import { GoogleStrategy } from '@/auth/google.strategy';
import { GoogleAuthController } from '@/auth/googleAuth.controller';
import { GoogleAuthService } from '@/auth/googleAuth.service';
import { UserModule } from '@/user/user.module';

@Module({
imports: [UserModule],
controllers: [GoogleAuthController],
providers: [GoogleStrategy],
providers: [GoogleStrategy, GoogleAuthService],
})
export class AuthModule {}
export class AuthModule {}
34 changes: 19 additions & 15 deletions packages/backend/src/auth/google.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { Inject, UnauthorizedException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20';
import { Logger } from 'winston';
import { GoogleAuthService } from '@/auth/googleAuth.service';
import { OauthType } from '@/user/domain/ouathType';

export interface OauthUserInfo {
type: OauthType;
oauthId: string;
email?: string;
givenName?: string;
familyName?: string;
}

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy) {
constructor(@Inject('winston') private readonly logger: Logger) {
constructor(private readonly googleAuthService: GoogleAuthService) {
super({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
Expand All @@ -27,21 +37,15 @@ export class GoogleStrategy extends PassportStrategy(Strategy) {
done: VerifyCallback,
) {
const { id, emails, name, provider } = profile;
if (!emails) {
done(new UnauthorizedException('email is required'), false);
return;
}
if (!name) {
done(new UnauthorizedException('name is required'), false);
return;
}

const userInfo = {
type: provider,
type: provider.toUpperCase() as OauthType,
oauthId: id,
emails: emails[0].value,
nickname: `${name.givenName} ${name.familyName}`,
email: emails?.[0].value,
givenName: name?.givenName,
familyName: name?.familyName,
};
this.logger.info(`google user info: ${JSON.stringify(userInfo)}`);
const user = await this.googleAuthService.attemptAuthentication(userInfo);
done(null, false);
}
}
97 changes: 97 additions & 0 deletions packages/backend/src/auth/googleAuth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { OauthUserInfo } from '@/auth/google.strategy';
import { GoogleAuthService } from '@/auth/googleAuth.service';
import { OauthType } from '@/user/domain/ouathType';
import { Role } from '@/user/domain/role';
import { User } from '@/user/domain/user.entity';
import { UserService } from '@/user/user.service';

describe('GoogleAuthService 테스트', () => {
const userInfo: OauthUserInfo = {
type: OauthType.GOOGLE,
givenName: 'Homer Jay',
familyName: 'Simpson',
email: '[email protected]',
oauthId: '12345678910',
};

test('oauthId와 type에 맞는 유저가 있으면 해당 객체를 반환한다.', async () => {
const user: User = {
id: 1,
role: Role.USER,
type: OauthType.GOOGLE,
isLight: true,
};
const userService: Partial<UserService> = {
findUserByOauthIdAndType: jest.fn().mockResolvedValue(user),
};
const authService = new GoogleAuthService(userService as UserService);

const authUser = await authService.attemptAuthentication(userInfo);

expect(authUser).toBe(user);
});

test('자동 회원가입시 이메일이 없으면 예외가 발생한다.', async () => {
const userInfo: OauthUserInfo = {
type: OauthType.GOOGLE,
givenName: 'Homer Jay',
familyName: 'Simpson',
oauthId: '12345678910',
};
const userService: Partial<UserService> = {
findUserByOauthIdAndType: jest.fn().mockResolvedValue(null),
};
const authService = new GoogleAuthService(userService as UserService);

await expect(() =>
authService.attemptAuthentication(userInfo),
).rejects.toThrow('email is required');
});

test('자동 회원가입시 givenName과 FamilyName이 없으면 예외가 발생한다.', async () => {
const userInfo: OauthUserInfo = {
type: OauthType.GOOGLE,
email: '[email protected]',
oauthId: '12345678910',
};
const userService: Partial<UserService> = {
findUserByOauthIdAndType: jest.fn().mockResolvedValue(null),
};
const authService = new GoogleAuthService(userService as UserService);

await expect(() =>
authService.attemptAuthentication(userInfo),
).rejects.toThrow('name is required');
});

test.each([
['Homer Jay', 'Simpson', 'Homer Jay Simpson'],
['Homer Jay', undefined, 'Homer Jay'],
[undefined, 'Simpson', 'Simpson'],
])(
'이름을 자동 생성 후 가입한다.',
async (givenName, familyName, expected) => {
const userInfo: OauthUserInfo = {
type: OauthType.GOOGLE,
givenName,
familyName,
email: '[email protected]',
oauthId: '12345678910',
};
const userService: Partial<UserService> = {
findUserByOauthIdAndType: jest.fn().mockResolvedValue(null),
register: jest.fn(),
};
const authService = new GoogleAuthService(userService as UserService);

await authService.attemptAuthentication(userInfo);

expect(userService.register).toHaveBeenCalledWith({
type: userInfo.type,
nickname: expected,
email: userInfo.email,
oauthId: userInfo.oauthId,
});
},
);
});
32 changes: 32 additions & 0 deletions packages/backend/src/auth/googleAuth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { OauthUserInfo } from '@/auth/google.strategy';
import { UserService } from '@/user/user.service';

@Injectable()
export class GoogleAuthService {
constructor(private readonly userService: UserService) {}

async attemptAuthentication(userInfo: OauthUserInfo) {
const { email, givenName, familyName, oauthId, type } = userInfo;
const user = await this.userService.findUserByOauthIdAndType(oauthId, type);
if (user) {
return user;
}
if (!email) {
throw new UnauthorizedException('email is required');
}
if (!givenName && !familyName) {
throw new UnauthorizedException('name is required');
}
return await this.userService.register({
type,
nickname: this.createName(givenName, familyName),
email: email as string,
oauthId,
});
}

private createName(givenName?: string, familyName?: string) {
return `${givenName ? `${givenName} ` : ''}${familyName ? familyName : ''}`.trim();
}
}
4 changes: 2 additions & 2 deletions packages/backend/src/common/dateEmbedded.entity.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';

export class DateEmbedded {
@CreateDateColumn({ name: 'created_at' })
@CreateDateColumn({ type: 'timestamp', name: 'created_at' })
createdAt?: Date;

@UpdateDateColumn({ name: 'updated_at' })
@UpdateDateColumn({ type: 'timestamp', name: 'updated_at' })
updatedAt?: Date;
}
23 changes: 15 additions & 8 deletions packages/backend/src/stock/stock.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { DataSource } from 'typeorm';
import { Logger } from 'winston';
import { StockService } from './stock.service';
import { createDataSourceMock } from '@/user/user.service.spec';

const logger: Logger = {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
} as unknown as Logger;

describe('StockService 테스트', () => {
const stockId = 'A005930';
const userId = 1;
Expand All @@ -13,7 +20,7 @@ describe('StockService 테스트', () => {
increment: jest.fn().mockResolvedValue({ id: stockId, views: 1 }),
};
const dataSource = createDataSourceMock(managerMock);
const stockService = new StockService(dataSource as DataSource);
const stockService = new StockService(dataSource as DataSource, logger);

await stockService.increaseView(stockId);

Expand All @@ -25,7 +32,7 @@ describe('StockService 테스트', () => {
exists: jest.fn().mockResolvedValue(false),
};
const dataSource = createDataSourceMock(managerMock);
const stockService = new StockService(dataSource as DataSource);
const stockService = new StockService(dataSource as DataSource, logger);

await expect(async () => stockService.increaseView('1')).rejects.toThrow(
'stock not found',
Expand All @@ -41,7 +48,7 @@ describe('StockService 테스트', () => {
insert: jest.fn(),
};
const dataSource = createDataSourceMock(managerMock);
const stockService = new StockService(dataSource as DataSource);
const stockService = new StockService(dataSource as DataSource, logger);

await stockService.createUserStock(userId, stockId);

Expand All @@ -54,7 +61,7 @@ describe('StockService 테스트', () => {
exists: jest.fn().mockResolvedValue(false),
};
const dataSource = createDataSourceMock(managerMock);
const stockService = new StockService(dataSource as DataSource);
const stockService = new StockService(dataSource as DataSource, logger);

await expect(() =>
stockService.createUserStock(userId, 'A'),
Expand All @@ -66,7 +73,7 @@ describe('StockService 테스트', () => {
exists: jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(true),
};
const dataSource = createDataSourceMock(managerMock);
const stockService = new StockService(dataSource as DataSource);
const stockService = new StockService(dataSource as DataSource, logger);

await expect(async () =>
stockService.createUserStock(userId, stockId),
Expand All @@ -79,7 +86,7 @@ describe('StockService 테스트', () => {
delete: jest.fn(),
};
const dataSource = createDataSourceMock(managerMock);
const stockService = new StockService(dataSource as DataSource);
const stockService = new StockService(dataSource as DataSource, logger);

await stockService.deleteUserStock(userId, userStockId);

Expand All @@ -92,7 +99,7 @@ describe('StockService 테스트', () => {
findOne: jest.fn().mockResolvedValue(null),
};
const dataSource = createDataSourceMock(managerMock);
const stockService = new StockService(dataSource as DataSource);
const stockService = new StockService(dataSource as DataSource, logger);

await expect(() => stockService.deleteUserStock(userId, 2)).rejects.toThrow(
'user stock not found',
Expand All @@ -105,7 +112,7 @@ describe('StockService 테스트', () => {
findOne: jest.fn().mockResolvedValue({ user: { id: userId } }),
};
const dataSource = createDataSourceMock(managerMock);
const stockService = new StockService(dataSource as DataSource);
const stockService = new StockService(dataSource as DataSource, logger);

await expect(() =>
stockService.deleteUserStock(notOwnerUserId, userStockId),
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/user/domain/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export class User {
@Column({ length: 10, default: OauthType.LOCAL })
type: OauthType = OauthType.LOCAL;

@Column({ name: 'oauth_id' })
oauthId?: number;
@Column('decimal', { name: 'oauth_id' })
oauthId?: string;

@Column({ name: 'is_light', default: true })
isLight: boolean = true;
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/user/user.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ import { UserService } from '@/user/user.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
2 changes: 1 addition & 1 deletion packages/backend/src/user/user.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('UserService 테스트', () => {
email: '[email protected]',
type: OauthType.GOOGLE,
nickname: 'test',
oauthId: 1,
oauthId: '123123231242141',
};

test('유저를 생성한다', async () => {
Expand Down
7 changes: 3 additions & 4 deletions packages/backend/src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class UserService {
constructor(private readonly dataSources: DataSource) {}

async register({ nickname, email, type, oauthId }: RegisterRequest) {
const user = await this.dataSources.transaction(async (manager) => {
return await this.dataSources.transaction(async (manager) => {
await this.validateUserExists(type, oauthId, manager);
return await manager.save(User, {
nickname,
Expand All @@ -21,18 +21,17 @@ export class UserService {
oauthId,
});
});
return { nickname: user.nickname, email: user.email, type: user.type };
}

async findUserByOauthIdAndType(oauthId: number, type: OauthType) {
async findUserByOauthIdAndType(oauthId: string, type: OauthType) {
return await this.dataSources.manager.findOne(User, {
where: { oauthId, type },
});
}

private async validateUserExists(
type: OauthType,
oauthId: number,
oauthId: string,
manager: EntityManager,
) {
if (await manager.exists(User, { where: { oauthId, type } })) {
Expand Down

0 comments on commit 53e7fc2

Please sign in to comment.