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
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{
"name": "ship",
"version": "1.0.0",
"keywords": ["ship", "next", "koa"],
"keywords": [
"ship",
"next",
"koa"
],
"author": "Ship Team <ship@paralect.com>",
"license": "MIT",
"scripts": {
Expand Down
2 changes: 2 additions & 0 deletions template/apps/api/src/resources/account/account.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { routeUtil } from 'utils';
import forgotPassword from './actions/forgot-password';
import get from './actions/get';
import google from './actions/google';
import googleMobile from './actions/google-mobile';
import resendEmail from './actions/resend-email';
import resetPassword from './actions/reset-password';
import signIn from './actions/sign-in';
Expand All @@ -22,6 +23,7 @@ const publicRoutes = routeUtil.getRoutes([
verifyResetToken,
resendEmail,
google,
googleMobile,
]);

const privateRoutes = routeUtil.getRoutes([get, update]);
Expand Down
44 changes: 44 additions & 0 deletions template/apps/api/src/resources/account/actions/google-mobile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { z } from 'zod';

import { userService } from 'resources/user';

import { rateLimitMiddleware, validateMiddleware } from 'middlewares';
import { authService, googleService } from 'services';

import { AppKoaContext, AppRouter, Next, User } from 'types';

const schema = z.object({
idToken: z.string().min(1, 'ID token is required'),
});

interface ValidatedData extends z.infer<typeof schema> {
user: User;
}

async function validator(ctx: AppKoaContext<ValidatedData>, next: Next) {
const { idToken } = ctx.validatedData;

const user = await googleService.validateIdToken(idToken);

ctx.assertClientError(user, {
credentials: 'Failed to authenticate with Google',
});

ctx.validatedData.user = user;
await next();
}

async function handler(ctx: AppKoaContext<ValidatedData>) {
const { user } = ctx.validatedData;

const accessToken = await authService.setAccessToken({ ctx, userId: user._id });

ctx.body = {
accessToken,
user: userService.getPublic(user),
};
}

export default (router: AppRouter) => {
router.post('/sign-in/google-mobile', rateLimitMiddleware(), validateMiddleware(schema), validator, handler);
};
13 changes: 11 additions & 2 deletions template/apps/api/src/resources/account/actions/sign-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { userService } from 'resources/user';

import { rateLimitMiddleware, validateMiddleware } from 'middlewares';
import { authService } from 'services';
import { securityUtil } from 'utils';
import { clientUtil, securityUtil } from 'utils';

import { signInSchema } from 'schemas';
import { AppKoaContext, AppRouter, Next, SignInParams, TokenType, User } from 'types';
Expand Down Expand Up @@ -48,8 +48,17 @@ async function validator(ctx: AppKoaContext<ValidatedData>, next: Next) {

async function handler(ctx: AppKoaContext<ValidatedData>) {
const { user } = ctx.validatedData;
const clientType = clientUtil.detectClientType(ctx);

await authService.setAccessToken({ ctx, userId: user._id });
const accessToken = await authService.setAccessToken({ ctx, userId: user._id });

if (clientType === clientUtil.ClientType.MOBILE) {
ctx.body = {
accessToken,
user: userService.getPublic(user),
};
return;
}

ctx.body = userService.getPublic(user);
}
Expand Down
11 changes: 10 additions & 1 deletion template/apps/api/src/resources/account/actions/sign-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { userService } from 'resources/user';

import { rateLimitMiddleware, validateMiddleware } from 'middlewares';
import { emailService } from 'services';
import { securityUtil } from 'utils';
import { clientUtil, securityUtil } from 'utils';

import config from 'config';

Expand All @@ -25,6 +25,7 @@ async function validator(ctx: AppKoaContext<SignUpParams>, next: Next) {

async function handler(ctx: AppKoaContext<SignUpParams>) {
const { firstName, lastName, email, password } = ctx.validatedData;
const clientType = clientUtil.detectClientType(ctx);

const user = await userService.insertOne({
email,
Expand All @@ -50,6 +51,14 @@ async function handler(ctx: AppKoaContext<SignUpParams>) {
},
});

if (clientType === clientUtil.ClientType.MOBILE) {
ctx.body = {
emailVerificationToken: config.IS_DEV ? emailVerificationToken : undefined,
user: userService.getPublic(user),
};
return;
}

if (config.IS_DEV) {
ctx.body = { emailVerificationToken };
return;
Expand Down
42 changes: 38 additions & 4 deletions template/apps/api/src/resources/account/actions/verify-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { userService } from 'resources/user';

import { rateLimitMiddleware, validateMiddleware } from 'middlewares';
import { authService, emailService } from 'services';
import { clientUtil } from 'utils';

import config from 'config';

Expand All @@ -25,6 +26,11 @@ async function validator(ctx: AppKoaContext<ValidatedData>, next: Next) {
const user = await userService.findOne({ _id: emailVerificationToken?.userId });

if (!emailVerificationToken || !user) {
const clientType = clientUtil.detectClientType(ctx);
if (clientType === clientUtil.ClientType.MOBILE) {
ctx.throwClientError({ token: 'Token is invalid or expired' }, 400);
return;
}
ctx.throwGlobalErrorWithRedirect('Token is invalid or expired.');
return;
}
Expand All @@ -36,25 +42,43 @@ async function validator(ctx: AppKoaContext<ValidatedData>, next: Next) {
async function handler(ctx: AppKoaContext<ValidatedData>) {
try {
const { user } = ctx.validatedData;
const clientType = clientUtil.detectClientType(ctx);

await tokenService.invalidateUserTokens(user._id, TokenType.EMAIL_VERIFICATION);

await userService.updateOne({ _id: user._id }, () => ({ isEmailVerified: true }));
const updatedUser = await userService.updateOne({ _id: user._id }, () => ({ isEmailVerified: true }));

await authService.setAccessToken({ ctx, userId: user._id });
ctx.assertClientError(updatedUser, {
message: 'Failed to update user',
});

const accessToken = await authService.setAccessToken({ ctx, userId: user._id });

await emailService.sendTemplate<Template.SIGN_UP_WELCOME>({
to: user.email,
to: updatedUser.email,
subject: 'Welcome to Ship Community!',
template: Template.SIGN_UP_WELCOME,
params: {
firstName: user.firstName,
firstName: updatedUser.firstName,
href: `${config.WEB_URL}/sign-in`,
},
});

if (clientType === clientUtil.ClientType.MOBILE) {
ctx.body = {
accessToken,
user: userService.getPublic(updatedUser),
};
return;
}

ctx.redirect(config.WEB_URL);
} catch (error) {
const clientType = clientUtil.detectClientType(ctx);
if (clientType === clientUtil.ClientType.MOBILE) {
ctx.throwClientError({ message: 'Failed to verify email. Please try again.' }, 500);
return;
}
ctx.throwGlobalErrorWithRedirect('Failed to verify email. Please try again.');
}
}
Expand All @@ -70,4 +94,14 @@ export default (router: AppRouter) => {
validator,
handler,
);
router.post(
'/verify-email',
rateLimitMiddleware({
limitDuration: 60 * 60, // 1 hour
requestsPerDuration: 10,
}),
validateMiddleware(schema),
validator,
handler,
);
};
26 changes: 20 additions & 6 deletions template/apps/api/src/services/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { tokenService } from 'resources/token';
import { userService } from 'resources/user';

import { cookieUtil } from 'utils';
import { clientUtil, cookieUtil } from 'utils';

import { ACCESS_TOKEN } from 'app-constants';
import { AppKoaContext, Token, TokenType } from 'types';
Expand All @@ -12,12 +12,20 @@ interface SetAccessTokenOptions {
}

export const setAccessToken = async ({ ctx, userId }: SetAccessTokenOptions) => {
const clientType = clientUtil.detectClientType(ctx);
const tokenType = clientType === clientUtil.ClientType.MOBILE ? TokenType.ACCESS_MOBILE : TokenType.ACCESS;

const accessToken = await tokenService.createToken({
userId,
type: TokenType.ACCESS,
type: tokenType,
expiresIn: ACCESS_TOKEN.INACTIVITY_TIMEOUT_SECONDS,
});

if (clientType === clientUtil.ClientType.MOBILE) {
userService.updateLastRequest(userId);
return accessToken;
}

await cookieUtil.setTokens({
ctx,
accessToken,
Expand All @@ -36,15 +44,21 @@ interface UnsetUserAccessTokenOptions {
export const unsetUserAccessToken = async ({ ctx }: UnsetUserAccessTokenOptions) => {
const { user } = ctx.state;

if (user) await tokenService.invalidateUserTokens(user._id, TokenType.ACCESS);
if (user) {
await tokenService.invalidateUserTokens(user._id, TokenType.ACCESS);
await tokenService.invalidateUserTokens(user._id, TokenType.ACCESS_MOBILE);
}

await cookieUtil.unsetTokens({ ctx });
};

export const validateAccessToken = async (value?: string | null): Promise<Token | null> => {
const token = await tokenService.validateToken(value, TokenType.ACCESS);
const accessToken = await tokenService.validateToken(value, TokenType.ACCESS);
const mobileToken = await tokenService.validateToken(value, TokenType.ACCESS_MOBILE);

const token = accessToken || mobileToken;

if (!token || token.type !== TokenType.ACCESS) return null;
if (!token || (token.type !== TokenType.ACCESS && token.type !== TokenType.ACCESS_MOBILE)) return null;

const now = new Date();

Expand All @@ -54,7 +68,7 @@ export const validateAccessToken = async (value?: string | null): Promise<Token
) {
const newExpiresOn = new Date(now.getTime() + ACCESS_TOKEN.INACTIVITY_TIMEOUT_SECONDS * 1000);

await tokenService.updateOne({ _id: token._id, type: TokenType.ACCESS }, () => ({ expiresOn: newExpiresOn }));
await tokenService.updateOne({ _id: token._id, type: token.type }, () => ({ expiresOn: newExpiresOn }));

token.expiresOn = newExpiresOn;
}
Expand Down
49 changes: 49 additions & 0 deletions template/apps/api/src/services/google/google.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,52 @@ export const validateCallback = async (params: {
googleUserId,
});
};

export const validateIdToken = async (idToken: string): Promise<User | null> => {
try {
const claims = decodeIdToken(idToken);
const parsedUserInfo = googleUserInfoSchema.safeParse(claims);

if (!parsedUserInfo.success) {
const errorMessage = 'Failed to validate Google user info';

logger.error(`[Google OAuth Mobile] ${errorMessage}`);
logger.error(z.treeifyError(parsedUserInfo.error).errors);

throw new Error(errorMessage);
}

const {
sub: googleUserId,
email,
email_verified: isEmailVerified,
picture: avatarUrl,
given_name: firstName,
family_name: lastName,
} = parsedUserInfo.data;

if (!isEmailVerified) {
throw new Error('Google account is not verified');
}

const existingUser = await handleExistingUser(googleUserId);

if (existingUser) return existingUser;

const existingUserByEmail = await handleExistingUserByEmail(email, googleUserId);

if (existingUserByEmail) return existingUserByEmail;

return createNewUser({
firstName,
lastName,
email,
isEmailVerified,
avatarUrl,
googleUserId,
});
} catch (error) {
logger.error(`[Google OAuth Mobile] ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
};
34 changes: 34 additions & 0 deletions template/apps/api/src/utils/client.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { COOKIES } from 'app-constants';
import { AppKoaContext } from 'types';

export enum ClientType {
WEB = 'web',
MOBILE = 'mobile',
}

export const detectClientType = (ctx: AppKoaContext): ClientType => {
const hasCookieToken = !!ctx.cookies.get(COOKIES.ACCESS_TOKEN);
const hasBearerToken = !!ctx.headers.authorization?.startsWith('Bearer ');
const clientTypeHeader = ctx.headers['x-client-type']?.toString().toLowerCase();
const userAgent = ctx.headers['user-agent']?.toString().toLowerCase() || '';

if (hasCookieToken) {
return ClientType.WEB;
}

if (hasBearerToken && !hasCookieToken) {
return ClientType.MOBILE;
}

if (clientTypeHeader === 'mobile') {
return ClientType.MOBILE;
}

const isMobileUserAgent = userAgent.includes('flutter') || userAgent.includes('dart') || userAgent.includes('mobile');

if (isMobileUserAgent && !hasCookieToken) {
return ClientType.MOBILE;
}

return ClientType.WEB;
};
3 changes: 2 additions & 1 deletion template/apps/api/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as caseUtil from './case.util';
import * as clientUtil from './client.util';
import configUtil from './config.util';
import cookieUtil from './cookie.util';
import objectUtil from './object.util';
import promiseUtil from './promise.util';
import routeUtil from './routes.util';
import * as securityUtil from './security.util';

export { caseUtil, configUtil, cookieUtil, objectUtil, promiseUtil, routeUtil, securityUtil };
export { caseUtil, clientUtil, configUtil, cookieUtil, objectUtil, promiseUtil, routeUtil, securityUtil };
1 change: 1 addition & 0 deletions template/packages/enums/src/token.enum.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export enum TokenType {
ACCESS = 'access',
ACCESS_MOBILE = 'access_mobile',
EMAIL_VERIFICATION = 'email-verification',
RESET_PASSWORD = 'reset-password',
}