diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..0e76c907 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5 @@ +{ + "name": "ship", + "version": "1.0.0", + "lockfileVersion": 1 +} diff --git a/package.json b/package.json index cf09f49a..c0f221b6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,11 @@ { "name": "ship", "version": "1.0.0", - "keywords": ["ship", "next", "koa"], + "keywords": [ + "ship", + "next", + "koa" + ], "author": "Ship Team ", "license": "MIT", "scripts": { diff --git a/template/apps/api/src/resources/account/account.routes.ts b/template/apps/api/src/resources/account/account.routes.ts index 14700b2f..cafd0f46 100644 --- a/template/apps/api/src/resources/account/account.routes.ts +++ b/template/apps/api/src/resources/account/account.routes.ts @@ -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'; @@ -22,6 +23,7 @@ const publicRoutes = routeUtil.getRoutes([ verifyResetToken, resendEmail, google, + googleMobile, ]); const privateRoutes = routeUtil.getRoutes([get, update]); diff --git a/template/apps/api/src/resources/account/actions/google-mobile.ts b/template/apps/api/src/resources/account/actions/google-mobile.ts new file mode 100644 index 00000000..c8bf3ab0 --- /dev/null +++ b/template/apps/api/src/resources/account/actions/google-mobile.ts @@ -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 { + user: User; +} + +async function validator(ctx: AppKoaContext, 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) { + 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); +}; diff --git a/template/apps/api/src/resources/account/actions/sign-in.ts b/template/apps/api/src/resources/account/actions/sign-in.ts index 8d69bcd9..62db0a42 100644 --- a/template/apps/api/src/resources/account/actions/sign-in.ts +++ b/template/apps/api/src/resources/account/actions/sign-in.ts @@ -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'; @@ -48,8 +48,17 @@ async function validator(ctx: AppKoaContext, next: Next) { async function handler(ctx: AppKoaContext) { 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); } diff --git a/template/apps/api/src/resources/account/actions/sign-up.ts b/template/apps/api/src/resources/account/actions/sign-up.ts index c7b9f9d4..dd92735b 100644 --- a/template/apps/api/src/resources/account/actions/sign-up.ts +++ b/template/apps/api/src/resources/account/actions/sign-up.ts @@ -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'; @@ -25,6 +25,7 @@ async function validator(ctx: AppKoaContext, next: Next) { async function handler(ctx: AppKoaContext) { const { firstName, lastName, email, password } = ctx.validatedData; + const clientType = clientUtil.detectClientType(ctx); const user = await userService.insertOne({ email, @@ -50,6 +51,14 @@ async function handler(ctx: AppKoaContext) { }, }); + 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; diff --git a/template/apps/api/src/resources/account/actions/verify-email.ts b/template/apps/api/src/resources/account/actions/verify-email.ts index d1e26f75..b894e36e 100644 --- a/template/apps/api/src/resources/account/actions/verify-email.ts +++ b/template/apps/api/src/resources/account/actions/verify-email.ts @@ -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'; @@ -25,6 +26,11 @@ async function validator(ctx: AppKoaContext, 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; } @@ -36,25 +42,43 @@ async function validator(ctx: AppKoaContext, next: Next) { async function handler(ctx: AppKoaContext) { 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({ - 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.'); } } @@ -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, + ); }; diff --git a/template/apps/api/src/services/auth/auth.service.ts b/template/apps/api/src/services/auth/auth.service.ts index 4f803867..60008a97 100644 --- a/template/apps/api/src/services/auth/auth.service.ts +++ b/template/apps/api/src/services/auth/auth.service.ts @@ -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'; @@ -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, @@ -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 => { - 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(); @@ -54,7 +68,7 @@ export const validateAccessToken = async (value?: string | null): Promise ({ expiresOn: newExpiresOn })); + await tokenService.updateOne({ _id: token._id, type: token.type }, () => ({ expiresOn: newExpiresOn })); token.expiresOn = newExpiresOn; } diff --git a/template/apps/api/src/services/google/google.service.ts b/template/apps/api/src/services/google/google.service.ts index d8ec2391..326d5276 100644 --- a/template/apps/api/src/services/google/google.service.ts +++ b/template/apps/api/src/services/google/google.service.ts @@ -200,3 +200,52 @@ export const validateCallback = async (params: { googleUserId, }); }; + +export const validateIdToken = async (idToken: string): Promise => { + 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; + } +}; diff --git a/template/apps/api/src/utils/client.util.ts b/template/apps/api/src/utils/client.util.ts new file mode 100644 index 00000000..61ad6870 --- /dev/null +++ b/template/apps/api/src/utils/client.util.ts @@ -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; +}; diff --git a/template/apps/api/src/utils/index.ts b/template/apps/api/src/utils/index.ts index e9f81a94..d3302401 100644 --- a/template/apps/api/src/utils/index.ts +++ b/template/apps/api/src/utils/index.ts @@ -1,4 +1,5 @@ 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'; @@ -6,4 +7,4 @@ 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 }; diff --git a/template/packages/enums/src/token.enum.ts b/template/packages/enums/src/token.enum.ts index 165c3884..5ec00d92 100644 --- a/template/packages/enums/src/token.enum.ts +++ b/template/packages/enums/src/token.enum.ts @@ -1,5 +1,6 @@ export enum TokenType { ACCESS = 'access', + ACCESS_MOBILE = 'access_mobile', EMAIL_VERIFICATION = 'email-verification', RESET_PASSWORD = 'reset-password', }