Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
368 changes: 367 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dependencies": {
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"compression": "^1.8.1",
Expand All @@ -27,20 +28,32 @@
"dotenv": "^17.4.2",
"express": "^5.2.1",
"http-status-codes": "^2.3.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
"mysql2": "^3.22.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-naver-v2": "^2.0.8",
"reflect-metadata": "^0.2.2",
"tsoa": "^7.0.0-alpha.0",
"tsyringe": "^4.10.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/dotenv": "^6.1.1",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.10",
"@types/node": "^25.6.0",
"@types/passport": "^1.0.17",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"nodemon": "^3.1.14",
"prisma": "^7.8.0",
"tsx": "^4.21.0",
Expand Down
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ model user {
gender user_gender
birth DateTime? @db.DateTime(0)
email String @db.VarChar(255)
password String? @db.VarChar(255)
social_id String @db.VarChar(255)
social_type user_social_type?
phone_number String? @db.Char(11)
Expand Down Expand Up @@ -296,6 +297,7 @@ enum user_social_type {
NAVER
APPLE
GOOGLE
LOCAL
}

enum user_role {
Expand Down
286 changes: 286 additions & 0 deletions src/auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
import dotenv from 'dotenv';
import { Strategy as GoogleStrategy, Profile } from 'passport-google-oauth20';
import {
Strategy as NaverStrategy,
Profile as NaverProfile,
} from 'passport-naver-v2';
import { Strategy as LocalStrategy } from 'passport-local';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { prisma } from './config/db.config';
import { user_gender, user_social_type } from './generated/prisma/enums.js';
import { AppError } from './common/app-error';
import { USER_ERROR_CODE } from './common/error-code';
import { StatusCodes } from 'http-status-codes';

dotenv.config();

export type AuthUser = {
id: bigint;
email: string;
name: string;
};

export type AuthTokens = {
accessToken: string;
refreshToken: string;
};

export const generateAccessToken = (user: { id: bigint; email: string }) => {
return jwt.sign(
{ id: user.id.toString(), email: user.email },
process.env.JWT_SECRET!,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

환경변수를 !으로 단언하기 보다 변수로 선언해 존재하는지를 예외처리하는것이 좋아보입니다

const secrete = process.env.Secrete
if(!secrete)
    throw new Error("환경변수 Secrete이 선언되지 않았습니다"

{ expiresIn: '1h' },
);
};

export const generateRefreshToken = (user: { id: bigint }) => {
return jwt.sign({ id: user.id.toString() }, process.env.JWT_SECRET!, {
expiresIn: '14d',
});
};

const socialTypeLabel = (type: user_social_type | null): string => {
switch (type) {
case user_social_type.GOOGLE:
return 'Google';
case user_social_type.NAVER:
return '네이버';
// case user_social_type.KAKAO:
// return '카카오';
// case user_social_type.APPLE:
// return 'Apple';
case user_social_type.LOCAL:
return '이메일/비밀번호';
default:
return 'default';
}
};

const assertSignUpMethod = (
registered: user_social_type | null,
attempted: user_social_type,
): void => {
if (registered !== attempted) {
throw new AppError(
USER_ERROR_CODE.SIGNUP_METHOD_MISMATCH,
`이미 ${socialTypeLabel(registered)} 방식으로 가입된 이메일입니다. ${socialTypeLabel(registered)} 로그인을 이용해주세요.`,
StatusCodes.CONFLICT,
);
}
};

const googleVerify = async (profile: Profile): Promise<AuthUser> => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

구글로그인 인증과 동시에 사용자 회원가입을 진행하는것 보다 /google에서 로그인을 진행하고 만약 해당 사용자가 회원가입 되지 않은 사용자라면 json 응답에 응답 종류와 ticket으로 /google/register 라우터로 리다이렉트 시키는방식으로 구현하는게 좋을것 같습니다

const email = profile.emails?.[0]?.value;
if (!email) throw new Error('Google 프로필에 이메일이 없습니다.');

let user = await prisma.user.findFirst({ where: { email } });

if (!user) {
const nickname = (profile.displayName ?? email.split('@')[0] ?? 'user')
.replace(/\s+/g, '')
.slice(0, 10);

user = await prisma.user.create({
data: {
email,
nickname,
gender: user_gender.NONE,
birth: new Date(1970, 0, 1),
social_id: profile.id,
social_type: user_social_type.GOOGLE,
created_at: new Date(),
},
});
} else {
assertSignUpMethod(user.social_type, user_social_type.GOOGLE);
}

return { id: user.id, email: user.email, name: user.nickname };
};

export const googleStrategy = new GoogleStrategy(
{
clientID: process.env.PASSPORT_GOOGLE_CLIENT_ID!,
clientSecret: process.env.PASSPORT_GOOGLE_CLIENT_SECRET!,
callbackURL: '/oauth2/callback/google',
scope: ['email', 'profile'],
},
async (_accessToken, _refreshToken, profile, cb) => {
try {
const user = await googleVerify(profile);
const tokens: AuthTokens = {
accessToken: generateAccessToken(user),
refreshToken: generateRefreshToken(user),
};
return cb(null, tokens);
} catch (err) {
return cb(err as Error);
}
},
);

const naverVerify = async (profile: NaverProfile): Promise<AuthUser> => {
const email = profile.email;
if (!email) throw new Error('네이버 프로필에 이메일이 없습니다.');

let user = await prisma.user.findFirst({ where: { email } });

if (!user) {
const nickname = (profile.nickname ?? profile.name ?? email.split('@')[0] ?? 'user')
.replace(/\s+/g, '')
.slice(0, 10);

const gender =
profile.gender === 'M'
? user_gender.MALE
: profile.gender === 'F'
? user_gender.FEMALE
: user_gender.NONE;

user = await prisma.user.create({
data: {
email,
nickname,
gender,
birth: new Date(1970, 0, 1),
social_id: profile.id,
social_type: user_social_type.NAVER,
created_at: new Date(),
},
});
} else {
assertSignUpMethod(user.social_type, user_social_type.NAVER);
}

return { id: user.id, email: user.email, name: user.nickname };
};

export const naverStrategy = new NaverStrategy(
{
clientID: process.env.PASSPORT_NAVER_CLIENT_ID!,
clientSecret: process.env.PASSPORT_NAVER_CLIENT_SECRET!,
callbackURL: '/oauth2/callback/naver',
},
async (
accessToken: string,
refreshToken: string,
profile: NaverProfile,
cb: (err: Error | null, user?: AuthTokens) => void,
) => {
try {
const user = await naverVerify(profile);
const tokens: AuthTokens = {
accessToken: generateAccessToken(user),
refreshToken: generateRefreshToken(user),
};
return cb(null, tokens);
} catch (err) {
return cb(err as Error);
}
},
);

const BCRYPT_SALT_ROUNDS = 10;

export const hashPassword = (plain: string): Promise<string> =>
bcrypt.hash(plain, BCRYPT_SALT_ROUNDS);

export type LocalSignUpInput = {
email: string;
password: string;
name?: string;
};

export const localSignUp = async (
input: LocalSignUpInput,
): Promise<AuthUser> => {
const existing = await prisma.user.findFirst({
where: { email: input.email },
});
if (existing) {
throw new AppError(
USER_ERROR_CODE.EMAIL_ALREADY_EXISTS,
'이미 존재하는 이메일입니다.',
StatusCodes.CONFLICT,
);
}

const hashed = await hashPassword(input.password);
const nickname = (input.name ?? input.email.split('@')[0] ?? 'user')
.replace(/\s+/g, '')
.slice(0, 10);

const user = await prisma.user.create({
data: {
email: input.email,
password: hashed,
nickname,
gender: user_gender.NONE,
birth: new Date(1970, 0, 1),
social_id: `local:${input.email}`,
social_type: user_social_type.LOCAL,
created_at: new Date(),
},
});

return { id: user.id, email: user.email, name: user.nickname };
};

const localVerify = async (
email: string,
password: string,
): Promise<AuthUser> => {
const user = await prisma.user.findFirst({ where: { email } });

if (!user) {
throw new AppError(
USER_ERROR_CODE.INVALID_CREDENTIALS,
'이메일 또는 비밀번호가 올바르지 않습니다.',
StatusCodes.UNAUTHORIZED,
);
}

if (user.social_type !== user_social_type.LOCAL || !user.password) {
assertSignUpMethod(user.social_type, user_social_type.LOCAL);
throw new AppError(
USER_ERROR_CODE.INVALID_CREDENTIALS,
'이메일 또는 비밀번호가 올바르지 않습니다.',
StatusCodes.UNAUTHORIZED,
);
}

const matched = await bcrypt.compare(password, user.password);
if (!matched) {
throw new AppError(
USER_ERROR_CODE.INVALID_CREDENTIALS,
'이메일 또는 비밀번호가 올바르지 않습니다.',
StatusCodes.UNAUTHORIZED,
);
}

return { id: user.id, email: user.email, name: user.nickname };
};

export const localStrategy = new LocalStrategy(
{
usernameField: 'email',
passwordField: 'password',
session: false,
},
async (email, password, cb) => {
try {
const user = await localVerify(email, password);
const tokens: AuthTokens = {
accessToken: generateAccessToken(user),
refreshToken: generateRefreshToken(user),
};
return cb(null, tokens);
} catch (err) {
if (err instanceof AppError) {
return cb(null, false, { message: err.message });
}
return cb(err as Error);
}
},
);
2 changes: 2 additions & 0 deletions src/common/error-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export enum USER_ERROR_CODE {
USER_ALREADY_EXISTS = 'USER_ALREADY_EXISTS',
USER_NOT_AUTHORIZED = 'USER_NOT_AUTHORIZED',
USER_NOT_AUTHENTICATED = 'USER_NOT_AUTHENTICATED',
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
SIGNUP_METHOD_MISMATCH = 'SIGNUP_METHOD_MISMATCH',
}

export enum STORE_ERROR_CODE {
Expand Down
Loading