Last active
September 2, 2024 12:24
-
-
Save Aziz87/a805c78624ce6fda95c3ab5b43006684 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Body, Controller, Get, Headers, HttpException, HttpStatus, Post, Query, Req, Res, UnauthorizedException, UseGuards, UseInterceptors } from "@nestjs/common"; | |
import { ApiBody, ApiExcludeEndpoint, ApiHeader, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; | |
import { Response } from "express"; | |
import { Tags } from "../../swagger/tags.enum"; | |
import { ApiIntercepter } from "../../intercepters/api.intercepter"; | |
import { AppleOAuthGuard } from "@app/shared/guards/apple-0auth.guard"; | |
import { Public } from "@app/shared/guards"; | |
import { AppleLoginDto } from "@app/shared/dto/user/apple-login.dto"; | |
import { ExistingUserDTO, LoginResponse, NewUserDTO } from "@app/shared/dto"; | |
import { UserEntity, UserRequest } from "@app/shared/interfaces/user"; | |
import * as jwt from "jsonwebtoken"; | |
import { JwksClient } from "jwks-rsa"; | |
import { TransportService } from "@app/shared/modules/transport/transport.service"; | |
import { EventPattern } from "@nestjs/microservices"; | |
import { Topic } from "@app/shared/modules/transport/transport.topic"; | |
import { CMD } from "@app/shared/modules/transport/transport.command"; | |
import { ENV, RedisCacheService } from "@app/shared/modules"; | |
import { AppleAuthCallbackDto } from "@app/shared/dto/user/apple-auth-callback.dto"; | |
import { JwtService } from "@nestjs/jwt"; | |
@UseInterceptors(ApiIntercepter) | |
@ApiTags(Tags.USER) | |
@Controller("user/apple") | |
export class APIAppleController { | |
private jwts = new JwtService(); | |
constructor( | |
private readonly transport: TransportService, | |
private readonly redis: RedisCacheService, | |
private readonly env: ENV, | |
) {} | |
@EventPattern([Topic.USER + ".reply"]) | |
onUserReply(payload: any) { | |
this.transport.resolvePromise(payload.uuid, payload.response); | |
} | |
private client = new JwksClient({ | |
jwksUri: "https://appleid.apple.com/auth/keys", | |
}); | |
private async getApplePublicKey(kid: string): Promise<string> { | |
return this.redis.cached(10000, `apple_public_key_${kid}`, async () => { | |
const key = await this.client.getSigningKey(kid); | |
return key.getPublicKey(); | |
}); | |
} | |
@Public() | |
@ApiOperation({ | |
summary: "👨🏻💻 OAuth-Apple авторизация", | |
description: `Позволяет пользователю авторизоваться с помощью учетной записи Apple. | |
**Процесс работы:** | |
1. Пользователь нажимает кнопку авторизации Apple, и происходит редирект на страницу авторизации Apple. | |
2. Пользователь авторизуется с помощью учетной записи Apple, и происходит редирект на callback-URL Apple. | |
3. Если пользователь уже существует, то перекидывает на страницу /apple-login?token={token}&refreshToken={refreshToken}. | |
4. Используйте token для получения информации о пользователе. | |
`, | |
}) | |
@ApiResponse({ status: 200 }) | |
@Get("") | |
@UseGuards(AppleOAuthGuard) | |
async appleAuthRedirect() {} | |
@Post("callback") | |
@Public() | |
@ApiOperation({ | |
summary: "👨🏻💻 OAuth-Apple", | |
description: `Обработка коллбэка после авторизации через Apple. Информация о пользователе Apple проверяется, а если пользователь авторизуется впервые, то создается новый аккаунт. | |
**Принцип работы:** | |
1. Информация о пользователе, полученная от Apple, проверяется. | |
2. Если пользователь авторизуется впервые, создается новый аккаунт. | |
3. Если пользователь уже существует, то перекидывает на страницу /apple-login?token={token}&refreshToken={refreshToken}. | |
4. Используйте token для получения информации о пользователе. | |
**Важное:** | |
- Если информация о пользователе Apple недействительна, то авторизация может не пройти. | |
`, | |
}) | |
@ApiResponse({ status: 200 }) | |
@ApiExcludeEndpoint() | |
// @UseGuards(AppleOAuthGuard) | |
async appleCallback(@Res() res: Response, @Body() dto: AppleAuthCallbackDto) { | |
console.log("onCallback", dto); | |
const auth = await this.authResponse({ identityToken: dto.id_token, firstName: dto.user?.name?.firstName, lastName: dto.user?.name?.lastName }, undefined); | |
let redirectUrl = "https://" + this.env.get("DOMAIN") + "/apple-login/"; | |
redirectUrl += `?token=${auth.token}`; | |
redirectUrl += `&refreshToken=${auth.refreshToken}`; | |
res.redirect(redirectUrl); | |
} | |
@Post("login") | |
@Public() | |
@ApiOperation({ | |
summary: "👨🏻💻 OAuth - Apple авторизация на мобильном устройстве", | |
description: ` | |
**Процесс выполнения:** | |
1. Декодируется identityToken, полученный после успешной аутентификации Apple. | |
2. Если пользователь впервые логинится, создается новый аккаунт, возвращается такой же ответ что и при методе /login. | |
3. Если пользователь уже существует, информация о нем обновляется, возвращается такой же ответ что и при методе /login. | |
**Важное замечание:** | |
- Если токен аутентификации Apple недействителен, возникнет ошибка. | |
`, | |
}) | |
@ApiBody({ type: AppleLoginDto }) | |
@ApiResponse({ status: 201, type: LoginResponse }) | |
@ApiHeader({ name: "authorization", required: false }) | |
async appleLogin(@Req() req: UserRequest, @Body() dto: AppleLoginDto, @Headers("authorization") authorization: string) { | |
if (authorization) { | |
const authHeaderParts = (authorization as string).split(" "); | |
if (authHeaderParts.length === 2) { | |
const [, jwt] = authHeaderParts; | |
const data = await this.jwts.verifyAsync(jwt, { secret: this.env.get("JWT_SECRET") }); | |
let { exp, timestamp, ...user } = data; | |
if (exp - timestamp < 60) throw new UnauthorizedException("Token expired"); | |
return this.authResponse(dto, user.id); | |
} | |
} else { | |
return this.authResponse(dto, undefined); | |
} | |
} | |
private async authResponse(dto: AppleLoginDto, oldUserId: number): Promise<LoginResponse> { | |
const decodedIdToken = await this.verifyAppleIdToken(dto.identityToken); | |
console.log("decodedIdToken", decodedIdToken); | |
if (decodedIdToken.exp < Date.now() / 1000) throw new UnauthorizedException("Token expired"); | |
if (!decodedIdToken.sub) throw new UnauthorizedException("Invalid apple token, sub not found"); | |
if (oldUserId) { | |
const user:UserEntity = await this.transport.emitPromise(Topic.USER, CMD.USER_GET_BY_ID, { id: oldUserId }); | |
const email = decodedIdToken.email || user?.email || decodedIdToken.sub + "@apple_wo_email.com"; | |
if (!user) throw new UnauthorizedException("Old user not found"); | |
const loginDto = new ExistingUserDTO(); | |
loginDto.email = email; | |
loginDto.password = decodedIdToken.sub; | |
loginDto.appleId = decodedIdToken.sub; | |
loginDto.oldUserId = oldUserId; | |
return await this.transport.emitPromise(Topic.USER, CMD.USER_LOGIN, loginDto); | |
} else { | |
const email = decodedIdToken.email || decodedIdToken.sub + "@apple_wo_email.com"; | |
const user: UserEntity = await this.transport.emitPromise(Topic.USER, CMD.USER_GET_BY_APPLE, { appleId: decodedIdToken.sub, email:decodedIdToken.email }); | |
if (!user) { | |
const regDto = new NewUserDTO(); | |
regDto.email = email; | |
regDto.password = decodedIdToken.sub; | |
regDto.firstName = dto.firstName || ""; | |
regDto.lastName = dto.lastName || ""; | |
regDto.emailVerified = true; | |
regDto.bdate = dto.bdate || null; | |
regDto.gender = dto.gender || null; | |
regDto.appleId = decodedIdToken.sub; | |
return await this.transport.emitPromise(Topic.USER, CMD.USER_REG, regDto); | |
} else { | |
const loginDto = new ExistingUserDTO(); | |
loginDto.email = email; | |
loginDto.password = decodedIdToken.sub; | |
loginDto.appleId = decodedIdToken.sub; | |
return await this.transport.emitPromise(Topic.USER, CMD.USER_LOGIN, loginDto); | |
} | |
} | |
} | |
private async verifyAppleIdToken(idToken: string): Promise<any> { | |
const decodedHeader: any = jwt.decode(idToken, { complete: true }); | |
if (!decodedHeader || !decodedHeader.header) throw new UnauthorizedException("Invalid apple token"); | |
let audience = decodedHeader?.payload?.aud?.split(".").slice(0,3).join(".") === this.env.get("APPLE_AUTH_CLIENT_ID").split(".").slice(0,3).join(".") ? decodedHeader?.payload?.aud : this.env.get("APPLE_AUTH_CLIENT_ID") | |
const publicKey = await this.getApplePublicKey(decodedHeader.header.kid); | |
return new Promise((resolve, reject) => { | |
jwt.verify(idToken, publicKey, { algorithms: ["RS256"], issuer: "https://appleid.apple.com", audience }, (err, decoded) => { | |
if (err) { | |
console.log("err", err); | |
return reject(new UnauthorizedException()); | |
} | |
resolve(decoded); | |
}); | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment