From 9175bf645a2ce7b41f4e4b2bb2afce3b23a8f02b Mon Sep 17 00:00:00 2001 From: zzswang Date: Tue, 25 Feb 2025 19:01:59 +0800 Subject: [PATCH 01/12] refactor: oauth --- .env | 3 + Dockerfile | 2 +- README.md | 8 +- openapi.json | 215 ++++++++++++++---- package.json | 6 +- src/app.module.ts | 2 +- src/auth/api-key-auth.guard.ts | 2 +- src/auth/auth.controller.ts | 140 ++++++++---- src/auth/auth.module.ts | 4 +- src/auth/auth.service.ts | 73 +----- src/auth/dto/github.dto.ts | 14 +- src/auth/dto/oauth.dto.ts | 19 ++ src/captcha/captcha.service.ts | 2 +- src/captcha/entities/captcha.entity.ts | 2 +- src/{constants => config}/config.ts | 25 ++ src/config/index.ts | 1 + src/{constants => }/constants.ts | 5 - src/constants/index.ts | 2 - src/email/email.service.ts | 2 +- src/main.ts | 2 +- src/oauth/dto/oauth.dto.ts | 23 ++ .../entities/access-token-result.entity.ts | 9 + src/oauth/index.ts | 2 + src/oauth/oauth.module.ts | 11 + src/oauth/oauth.service.ts | 51 +++++ src/redis/redis.module.ts | 2 +- src/sms/sms.service.ts | 2 +- src/third-party/dto/bind-third-party.dto.ts | 8 +- .../entities/third-party.entity.ts | 52 +++-- src/third-party/third-party.service.ts | 14 +- src/user/user.controller.ts | 40 ++-- src/user/verify-identity.ts | 2 +- test/auth-login-logout.e2e-spec.ts | 2 +- test/captcha.e2e-spec.ts | 2 +- test/namespace.e2e-spec.ts | 2 +- test/user.e2e-spec.ts | 2 +- 36 files changed, 515 insertions(+), 238 deletions(-) create mode 100644 src/auth/dto/oauth.dto.ts rename src/{constants => config}/config.ts (55%) create mode 100644 src/config/index.ts rename src/{constants => }/constants.ts (79%) delete mode 100644 src/constants/index.ts create mode 100644 src/oauth/dto/oauth.dto.ts create mode 100644 src/oauth/entities/access-token-result.entity.ts create mode 100644 src/oauth/index.ts create mode 100644 src/oauth/oauth.module.ts create mode 100644 src/oauth/oauth.service.ts diff --git a/.env b/.env index da2df51..371ae2c 100644 --- a/.env +++ b/.env @@ -33,3 +33,6 @@ JWT_SECRET_KEY=cksfkewkfoptyhiop534o239dkja23as # API KEY API_KEY=YHImpSchS4iEwVD1IxXp4012 + +GITHUB_CLIENT_ID=Iv23lizBaVPIiABBCHaz +GITHUB_CLIENT_SECRET=041f46399c1396ec27d16851c1aa2aa479a3f5a5 diff --git a/Dockerfile b/Dockerfile index 635ace9..07c407f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,4 +38,4 @@ ENV NODE_ENV production RUN pnpm fetch --prod COPY --from=builder /app/dist ./dist RUN pnpm install --offline --prod -CMD [ "pnpm", "start:prod" ] +CMD [ "pnpm", "start" ] diff --git a/README.md b/README.md index 6f3ca56..f41a344 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,14 @@ pnpm install ```bash # development -$ pnpm start +$ pnpm dev # debug mode -$ pnpm start:debug +$ pnpm debug # production mode -$ pnpm start:prod +$ pnpm start # build dist $ pnpm build @@ -71,7 +71,7 @@ PORT=9528 ```sh ## 启动一下工程就会自动生成 openapi.json 文件 -NODE_ENV=development p start +NODE_ENV=development p dev ``` 生成 sdk diff --git a/openapi.json b/openapi.json index 2461018..335815d 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "386801e97226267fb36f1c8b0d100f752f6cb74466e161ba84e81c4d4b58a6ff", + "hash": "6facc3b7e3f1115a6e7ef21e2b97f6c272443477fb0f7f78797a8f2cd83bd3d6", "openapi": "3.0.0", "paths": { "/hello": { @@ -114,6 +114,39 @@ ] } }, + "/auth/@loginByOAuth": { + "post": { + "operationId": "loginByOAuth", + "summary": "", + "description": "login by OAuth", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthDto" + } + } + } + }, + "responses": { + "200": { + "description": "The session with token has been successfully created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionWithToken" + } + } + } + } + }, + "tags": [ + "auth" + ] + } + }, "/auth/@loginByEmail": { "post": { "operationId": "loginByEmail", @@ -1157,7 +1190,7 @@ "name": "key", "required": false, "in": "query", - "description": "命名空间的 key\n\n允许的字符 ^[a-zA-Z][a-zA-Z0-9._/-]{0,30}$", + "description": "命名空间的 key\n\n允许的字符 ^[a-zA-Z][a-zA-Z0-9._/-]{0,200}$", "schema": { "type": "string" } @@ -2729,14 +2762,14 @@ "in": "query", "description": "第三方登录来源", "schema": { - "$ref": "#/components/schemas/ThirdPartySource" + "type": "string" } }, { - "name": "login", + "name": "tid", "required": false, "in": "query", - "description": "第三方登录 id", + "description": "第三方登录的用户唯一标识", "schema": { "type": "string" } @@ -2751,21 +2784,41 @@ } }, { - "name": "avatar", + "name": "expireAt", "required": false, "in": "query", + "description": "第三方登录过期时间", + "schema": { + "type": "number" + } + }, + { + "name": "tokenType", + "required": false, + "in": "query", + "description": "第三方登录 token 类型", "schema": { "type": "string" } }, { - "name": "name", + "name": "refreshToken", "required": false, "in": "query", + "description": "第三方登录 refreshToken", "schema": { "type": "string" } }, + { + "name": "refreshTokenExpireAt", + "required": false, + "in": "query", + "description": "第三方登录 refreshToken 过期时间", + "schema": { + "type": "number" + } + }, { "name": "uid", "required": false, @@ -2775,6 +2828,15 @@ "type": "string" } }, + { + "name": "data", + "required": false, + "in": "query", + "description": "用于存储第三方的额外数据,应用自己选择", + "schema": { + "type": "string" + } + }, { "name": "_limit", "required": false, @@ -3386,12 +3448,42 @@ "properties": { "code": { "type": "string" + }, + "grant_type": { + "type": "string" + }, + "redirect_uri": { + "type": "string" + }, + "repository_id": { + "type": "string" } }, "required": [ "code" ] }, + "OAuthDto": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "code": { + "type": "string" + }, + "grant_type": { + "type": "string" + }, + "redirect_uri": { + "type": "string" + } + }, + "required": [ + "provider", + "code" + ] + }, "LoginByEmailDto": { "type": "object", "properties": { @@ -4076,7 +4168,7 @@ }, "key": { "type": "string", - "description": "命名空间的 key\n\n允许的字符 ^[a-zA-Z][a-zA-Z0-9._/-]{0,30}$" + "description": "命名空间的 key\n\n允许的字符 ^[a-zA-Z][a-zA-Z0-9._/-]{0,200}$" }, "ns": { "type": "string", @@ -4135,7 +4227,7 @@ }, "key": { "type": "string", - "description": "命名空间的 key\n\n允许的字符 ^[a-zA-Z][a-zA-Z0-9._/-]{0,30}$" + "description": "命名空间的 key\n\n允许的字符 ^[a-zA-Z][a-zA-Z0-9._/-]{0,200}$" }, "ns": { "type": "string", @@ -4918,42 +5010,49 @@ } } }, - "ThirdPartySource": { - "type": "string", - "description": "第三方登录来源", - "enum": [ - "github", - "wechat" - ] - }, "createThirdPartyDto": { "type": "object", "properties": { "source": { - "$ref": "#/components/schemas/ThirdPartySource" + "type": "string", + "description": "第三方登录来源" }, - "login": { + "tid": { "type": "string", - "description": "第三方登录 id" + "description": "第三方登录的用户唯一标识" }, "accessToken": { "type": "string", "description": "第三方登录 accessToken" }, - "avatar": { - "type": "string" + "expireAt": { + "type": "number", + "description": "第三方登录过期时间" }, - "name": { - "type": "string" + "tokenType": { + "type": "string", + "description": "第三方登录 token 类型" + }, + "refreshToken": { + "type": "string", + "description": "第三方登录 refreshToken" + }, + "refreshTokenExpireAt": { + "type": "number", + "description": "第三方登录 refreshToken 过期时间" }, "uid": { "type": "string", "description": "关联uid" + }, + "data": { + "type": "string", + "description": "用于存储第三方的额外数据,应用自己选择" } }, "required": [ "source", - "login", + "tid", "accessToken" ] }, @@ -4961,26 +5060,41 @@ "type": "object", "properties": { "source": { - "$ref": "#/components/schemas/ThirdPartySource" + "type": "string", + "description": "第三方登录来源" }, - "login": { + "tid": { "type": "string", - "description": "第三方登录 id" + "description": "第三方登录的用户唯一标识" }, "accessToken": { "type": "string", "description": "第三方登录 accessToken" }, - "avatar": { - "type": "string" + "expireAt": { + "type": "number", + "description": "第三方登录过期时间" }, - "name": { - "type": "string" + "tokenType": { + "type": "string", + "description": "第三方登录 token 类型" + }, + "refreshToken": { + "type": "string", + "description": "第三方登录 refreshToken" + }, + "refreshTokenExpireAt": { + "type": "number", + "description": "第三方登录 refreshToken 过期时间" }, "uid": { "type": "string", "description": "关联uid" }, + "data": { + "type": "string", + "description": "用于存储第三方的额外数据,应用自己选择" + }, "id": { "type": "string", "description": "Entity id" @@ -5006,7 +5120,7 @@ }, "required": [ "source", - "login", + "tid", "accessToken", "id" ] @@ -5015,25 +5129,40 @@ "type": "object", "properties": { "source": { - "$ref": "#/components/schemas/ThirdPartySource" + "type": "string", + "description": "第三方登录来源" }, - "login": { + "tid": { "type": "string", - "description": "第三方登录 id" + "description": "第三方登录的用户唯一标识" }, "accessToken": { "type": "string", "description": "第三方登录 accessToken" }, - "avatar": { - "type": "string" + "expireAt": { + "type": "number", + "description": "第三方登录过期时间" }, - "name": { - "type": "string" + "tokenType": { + "type": "string", + "description": "第三方登录 token 类型" + }, + "refreshToken": { + "type": "string", + "description": "第三方登录 refreshToken" + }, + "refreshTokenExpireAt": { + "type": "number", + "description": "第三方登录 refreshToken 过期时间" }, "uid": { "type": "string", "description": "关联uid" + }, + "data": { + "type": "string", + "description": "用于存储第三方的额外数据,应用自己选择" } } }, @@ -5047,11 +5176,7 @@ "type": "string" }, "source": { - "type": "string", - "enum": [ - "github", - "wechat" - ] + "type": "string" }, "login": { "type": "string" diff --git a/package.json b/package.json index 4a8cf68..0d824ac 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,9 @@ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "gen:sdk": "ts-node scripts/gen-sdk.ts -o ./sdk", "mock": "node bin/mock.js", - "start": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", + "dev": "nest start --watch", + "debug": "nest start --debug --watch", + "start": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "test": "jest", "test:watch": "jest --watch", diff --git a/src/app.module.ts b/src/app.module.ts index 21306aa..3525afe 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,7 +7,7 @@ import { MongooseModule } from '@nestjs/mongoose/dist/mongoose.module'; import { Cache } from 'cache-manager'; import { redisClusterInsStore, redisInsStore } from 'cache-manager-redis-yet'; -import * as config from 'src/constants'; +import * as config from 'src/config'; import { AppController } from './app.controller'; import { ApiKeyAuthGuard, AuthModule } from './auth'; diff --git a/src/auth/api-key-auth.guard.ts b/src/auth/api-key-auth.guard.ts index 595246e..a611bc3 100644 --- a/src/auth/api-key-auth.guard.ts +++ b/src/auth/api-key-auth.guard.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { auth } from 'src/constants'; +import { auth } from 'src/config'; export const IS_PUBLIC_KEY = 'isPublic'; diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 07792d6..47603a1 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -12,19 +12,22 @@ import { } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { get } from 'lodash'; import { JwtPayload } from 'src/auth'; import { CaptchaService } from 'src/captcha'; +import * as config from 'src/config'; import { ErrorCodes } from 'src/constants'; -import * as config from 'src/constants'; import { addShortTimeSpan } from 'src/lib/lang/time'; +import { OAuthService } from 'src/oauth'; import { CreateSessionDto, SessionService } from 'src/session'; -import { ThirdPartySource } from 'src/third-party'; +import { ThirdPartyDoc, ThirdPartyService } from 'src/third-party'; import { User, UserDocument, UserService } from 'src/user'; import { AuthService } from './auth.service'; import { GithubDto } from './dto/github.dto'; import { LoginByEmailDto, LoginByPhoneDto, LoginDto, LogoutDto } from './dto/login.dto'; +import { OAuthDto } from './dto/oauth.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; import { RegisterByEmailDto, RegisterbyPhoneDto, RegisterDto } from './dto/register.dto'; import { ResetPasswordByEmailDto, ResetPasswordByPhoneDto } from './dto/reset-password.dto'; @@ -42,7 +45,9 @@ export class AuthController { private readonly userService: UserService, private readonly jwtService: JwtService, private readonly captchaService: CaptchaService, - private readonly authService: AuthService + private readonly authService: AuthService, + private readonly oauthService: OAuthService, + private readonly thirdPartyService: ThirdPartyService ) {} _login = async (user: UserDocument): Promise => { @@ -80,6 +85,32 @@ export class AuthController { return res; }; + _loginByThirdParty = async (thirdParty: ThirdPartyDoc): Promise => { + const subject = `${thirdParty.source}|${thirdParty.tid}`; + const session = await this.sessionService.create({ + subject, + refreshTokenExpireAt: addShortTimeSpan(SESSION_EXPIRES_IN), // session 先固定 7 天过期吧 + }); + + const jwtpayload: JwtPayload = { + sid: session.id, + }; + + const tokenExpireAt = addShortTimeSpan(TOKEN_EXPIRES_IN); + const token = this.jwtService.sign(jwtpayload, { + expiresIn: TOKEN_EXPIRES_IN, + subject, + }); + + const res: SessionWithToken = { + ...session.toJSON(), + token, + tokenExpireAt, + }; + + return res; + }; + /** * login with username/phone/email and password */ @@ -127,25 +158,63 @@ export class AuthController { }) @Post('@loginByGithub') async loginByGithub(@Body() githubDto: GithubDto): Promise { - const { code } = githubDto; - const githubAccessToken = await this.authService.getGithubAccessToken(code); - if (!githubAccessToken) { - throw new UnauthorizedException({ - code: ErrorCodes.AUTH_FAILED, - message: `github access token not found.`, - }); - } - const githubUser = await this.authService.getGithubUser(githubAccessToken); - if (!githubUser) { - throw new UnauthorizedException({ - code: ErrorCodes.AUTH_FAILED, - message: `github user not found.`, - }); - } + return this.loginByOAuth({ + provider: 'github', + code: githubDto.code, + grant_type: githubDto.grant_type, + redirect_uri: githubDto.redirect_uri, + }); + } - // github 已绑定用户 - if (githubUser.uid) { - const user = await this.userService.get(githubUser.uid); + /** + * login by OAuth + */ + @ApiOperation({ operationId: 'loginByOAuth' }) + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ + description: 'The session with token has been successfully created.', + type: SessionWithToken, + }) + @Post('@loginByOAuth') + async loginByOAuth(@Body() dto: OAuthDto): Promise { + const { provider, code, grant_type, redirect_uri } = dto; + const clientId = config.oauthProvider.clientId(provider); + const clientSecret = config.oauthProvider.clientSecret(provider); + const accessTokenUrl = config.oauthProvider.accessTokenUrl(provider); + + const result = await this.oauthService.getAccessToken(accessTokenUrl, { + client_id: clientId, + client_secret: clientSecret, + code, + grant_type, + redirect_uri, + }); + const expireAt = result.expires_in ? Date.now() + result.expires_in * 1000 : undefined; + const refreshTokenExpireAt = result.refresh_token_expires_in + ? Date.now() + result.refresh_token_expires_in * 1000 + : undefined; + + // 获取第三方的用户信息 + const userInfoUrl = config.oauthProvider.userInfoUrl(provider); + const userInfo = await this.oauthService.getUserInfo(userInfoUrl, result.access_token); + + // 创建或更新第三方数据 + const tidField = config.oauthProvider.tidField(provider); + const tid = get(userInfo, tidField); + const thirdParty = await this.thirdPartyService.upsert(tid, provider, { + tid, + source: provider, + accessToken: result.access_token, + expireAt, + tokenType: result.token_type, + refreshToken: result.refresh_token, + refreshTokenExpireAt, + data: JSON.stringify(userInfo), + }); + + // 已绑定用户 + if (thirdParty.uid) { + const user = await this.userService.get(thirdParty.uid); if (!user) { throw new UnauthorizedException({ code: ErrorCodes.AUTH_FAILED, @@ -156,27 +225,8 @@ export class AuthController { return this._login(user); } - // github 未绑定用户 - const session = await this.sessionService.create({ - subject: `${ThirdPartySource.GITHUB}|${githubUser.login}`, - refreshTokenExpireAt: addShortTimeSpan(SESSION_EXPIRES_IN), // session 先固定 7 天过期吧 - }); - - const jwtpayload: JwtPayload = {}; - - const tokenExpireAt = addShortTimeSpan(TOKEN_EXPIRES_IN); - const token = this.jwtService.sign(jwtpayload, { - expiresIn: TOKEN_EXPIRES_IN, - subject: `${ThirdPartySource.GITHUB}|${githubUser.login}`, - }); - - const res: SessionWithToken = { - ...session.toJSON(), - token, - tokenExpireAt, - }; - - return res; + // 未绑定用户 + return this._loginByThirdParty(thirdParty); } /** @@ -344,7 +394,7 @@ export class AuthController { const user = await this.userService.get(dto.uid); if (!user) { throw new NotFoundException({ - code: config.ErrorCodes.USER_NOT_FOUND, + code: ErrorCodes.USER_NOT_FOUND, message: `user ${dto.uid} not found.`, }); } @@ -382,14 +432,14 @@ export class AuthController { let session = await this.sessionService.findByRefreshToken(dto.refreshToken); if (!session) { throw new UnauthorizedException({ - code: config.ErrorCodes.SESSION_NOT_FOUND, + code: ErrorCodes.SESSION_NOT_FOUND, message: `session with refresh token ${dto.refreshToken} not found.`, }); } if (session.refreshTokenExpireAt.getTime() < Date.now()) { throw new UnauthorizedException({ - code: config.ErrorCodes.SESSION_EXPIRED, + code: ErrorCodes.SESSION_EXPIRED, message: 'Session has been expired.', }); } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index dcf30f4..1684fc6 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -4,10 +4,11 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { CaptchaModule } from 'src/captcha'; -import * as config from 'src/constants'; +import * as config from 'src/config'; import { EmailModule } from 'src/email'; import { GroupModule } from 'src/group'; import { NamespaceModule } from 'src/namespace'; +import { OAuthModule } from 'src/oauth'; import { RedisModule } from 'src/redis'; import { SessionModule } from 'src/session'; import { SmsModule } from 'src/sms'; @@ -36,6 +37,7 @@ import { AuthService } from './auth.service'; EmailModule, SmsModule, ThirdPartyModule, + OAuthModule, ], controllers: [AuthController], providers: [AuthService], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d3b3e06..e21c090 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,15 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { RedisClientType } from 'redis'; -import * as config from 'src/constants'; -import { - GithubAccessTokenUrl, - GithubClientId, - GithubClientSecret, - GithubUserUrl, -} from 'src/constants'; -import { createThirdPartyDto } from 'src/third-party/dto/create-third-party.dto'; -import { ThirdPartyDoc, ThirdPartySource } from 'src/third-party/entities/third-party.entity'; +import * as config from 'src/config'; import { ThirdPartyService } from 'src/third-party/third-party.service'; @Injectable() @@ -39,67 +31,4 @@ export class AuthService { // 设置过期时间为登录锁定时长(秒) await this.redisClient.expire(lockKey, config.auth.loginLockInS); } - - async getGithubAccessToken(code: string): Promise { - try { - // POST 请求到 GitHub 交换 access_token - const response = await fetch(GithubAccessTokenUrl, { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: GithubClientId, - client_secret: GithubClientSecret, - code, - }), - }); - const data = await response.json(); - if (!data.access_token) { - throw new Error('Failed to get access token'); - } - return data.access_token; - } catch (e) { - console.error(e); - return ''; - } - } - - async getGithubUser(accessToken: string): Promise { - try { - // 向 GitHub 用户信息 API 发送 GET 请求 - const response = await fetch(GithubUserUrl, { - method: 'GET', - headers: { - // 必须在请求头中传入 Authorization,格式为:Bearer - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }); - - // 如果请求失败,比如 token 无效或过期,处理错误 - if (!response.ok) { - console.error(`Failed to fetch user info: ${response.status} ${response.statusText}`); - return null; - } - - // 解析 JSON 返回的用户信息 - const userData = await response.json(); - // 创建或更新第三方登录信息 - const createDto: createThirdPartyDto = { - login: userData.login, - source: ThirdPartySource.GITHUB, - accessToken, - avatar: userData.avatar_url, - name: userData.name, - }; - - return this.thirdPartyService.upsert(createDto.login, createDto.source, createDto); - } catch (error) { - // 捕获网络或代码错误 - console.error('Error while fetching GitHub user info:', error); - return null; - } - } } diff --git a/src/auth/dto/github.dto.ts b/src/auth/dto/github.dto.ts index e013c08..24cafd9 100644 --- a/src/auth/dto/github.dto.ts +++ b/src/auth/dto/github.dto.ts @@ -1,7 +1,19 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class GithubDto { @IsNotEmpty() @IsString() code: string; + + @IsOptional() + @IsString() + grant_type?: string; + + @IsOptional() + @IsString() + redirect_uri?: string; + + @IsOptional() + @IsString() + repository_id?: string; } diff --git a/src/auth/dto/oauth.dto.ts b/src/auth/dto/oauth.dto.ts new file mode 100644 index 0000000..0b2a6a9 --- /dev/null +++ b/src/auth/dto/oauth.dto.ts @@ -0,0 +1,19 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class OAuthDto { + @IsNotEmpty() + @IsString() + provider: string; + + @IsNotEmpty() + @IsString() + code: string; + + @IsNotEmpty() + @IsString() + grant_type?: string; + + @IsOptional() + @IsString() + redirect_uri?: string; +} diff --git a/src/captcha/captcha.service.ts b/src/captcha/captcha.service.ts index 764937f..9f201ee 100644 --- a/src/captcha/captcha.service.ts +++ b/src/captcha/captcha.service.ts @@ -4,7 +4,7 @@ import dayjs from 'dayjs'; import { DeleteResult } from 'mongodb'; import { Model } from 'mongoose'; -import * as config from 'src/constants'; +import * as config from 'src/config'; import { buildMongooseQuery } from 'src/mongo'; import { CreateCaptchaDto } from './dto/create-captcha.dto'; diff --git a/src/captcha/entities/captcha.entity.ts b/src/captcha/entities/captcha.entity.ts index db9234c..262b259 100644 --- a/src/captcha/entities/captcha.entity.ts +++ b/src/captcha/entities/captcha.entity.ts @@ -3,7 +3,7 @@ import { IntersectionType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsDate, IsNotEmpty, IsString } from 'class-validator'; -import * as config from 'src/constants'; +import * as config from 'src/config'; import { SortFields } from 'src/lib/sort'; import { helper, MongoEntity } from 'src/mongo'; diff --git a/src/constants/config.ts b/src/config/config.ts similarity index 55% rename from src/constants/config.ts rename to src/config/config.ts index e822853..1c9e587 100644 --- a/src/constants/config.ts +++ b/src/config/config.ts @@ -2,6 +2,7 @@ import { toInteger, trimStart } from 'lodash'; import { EmailTransporter } from 'src/lib/email'; import { toBoolean } from 'src/lib/lang/boolean'; +import { toUpperSnakeCase } from 'src/lib/lang/string'; import { loadEnv } from '../lib/utils/env'; @@ -60,3 +61,27 @@ export const user = { appCode: 'appCode', }, }; + +export const oauthProvider = { + clientId: (provider: string) => loadEnv(`${toUpperSnakeCase(provider)}_CLIENT_ID`), + clientSecret: (provider: string) => loadEnv(`${toUpperSnakeCase(provider)}_CLIENT_SECRET`), + authorizeUrl: (provider: string) => loadEnv(`${toUpperSnakeCase(provider)}_AUTHORIZE_URL`), + accessTokenUrl: (provider: string) => loadEnv(`${toUpperSnakeCase(provider)}_ACCESS_TOKEN_URL`), + userInfoUrl: (provider: string) => loadEnv(`${toUpperSnakeCase(provider)}_USER_INFO_URL`), + tidField: (provider: string) => loadEnv(`${toUpperSnakeCase(provider)}_TID_FIELD`), // 第三方登录的用户唯一标识字段 +}; + +export const github = { + clientId: loadEnv('GITHUB_CLIENT_ID'), + clientSecret: loadEnv('GITHUB_CLIENT_SECRET'), + authorizeUrl: loadEnv('GITHUB_AUTHORIZE_URL', { + default: 'https://github.com/login/oauth/authorize', + }), + accessTokenUrl: loadEnv('GITHUB_ACCESS_TOKEN_URL', { + default: 'https://github.com/login/oauth/access_token', + }), + userInfoUrl: loadEnv('GITHUB_USER_INFO_URL', { + default: 'https://api.github.com/user', + }), + tidField: loadEnv('GITHUB_TID_FIELD', { default: 'login' }), // 第三方登录的用户唯一标识字段 +}; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..f03c228 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1 @@ +export * from './config'; diff --git a/src/constants/constants.ts b/src/constants.ts similarity index 79% rename from src/constants/constants.ts rename to src/constants.ts index a6a6805..caf921b 100644 --- a/src/constants/constants.ts +++ b/src/constants.ts @@ -25,8 +25,3 @@ export const ErrorCodes = { SMS_SEND_FAILED: 'SMS_SEND_FAILED', SMS_RECORD_NOT_FOUND: 'SMS_RECORD_NOT_FOUND', }; - -export const GithubAccessTokenUrl = 'https://github.com/login/oauth/access_token'; -export const GithubClientId = 'Iv23lizBaVPIiABBCHaz'; -export const GithubClientSecret = '041f46399c1396ec27d16851c1aa2aa479a3f5a5'; -export const GithubUserUrl = 'https://api.github.com/user'; diff --git a/src/constants/index.ts b/src/constants/index.ts deleted file mode 100644 index be1d300..0000000 --- a/src/constants/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './constants'; -export * from './config'; diff --git a/src/email/email.service.ts b/src/email/email.service.ts index 1f12305..bc7dbb3 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -1,6 +1,6 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import * as config from 'src/constants'; +import * as config from 'src/config'; import { EmailTransporter, Mailer } from 'src/lib/email'; import { SendEmailDto } from './dto/send-email.dto'; diff --git a/src/main.ts b/src/main.ts index 0875646..aa5ef31 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,7 +15,7 @@ import { MongoErrorsInterceptor } from 'src/mongo'; import { AppModule } from './app.module'; import { AllExceptionsFilter } from './common/all-exceptions.filter'; import { exceptionFactory } from './common/exception-factory'; -import { port, prefix } from './constants/config'; +import { port, prefix } from './config/config'; dayjs.extend(isoWeek); dayjs.extend(minMax); diff --git a/src/oauth/dto/oauth.dto.ts b/src/oauth/dto/oauth.dto.ts new file mode 100644 index 0000000..7a35988 --- /dev/null +++ b/src/oauth/dto/oauth.dto.ts @@ -0,0 +1,23 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class OAuthGetAccessTokenDto { + @IsNotEmpty() + @IsString() + client_id: string; + + @IsNotEmpty() + @IsString() + client_secret: string; + + @IsNotEmpty() + @IsString() + code: string; + + @IsOptional() + @IsString() + redirect_uri?: string; + + @IsOptional() + @IsString() + grant_type?: string; +} diff --git a/src/oauth/entities/access-token-result.entity.ts b/src/oauth/entities/access-token-result.entity.ts new file mode 100644 index 0000000..ca4d059 --- /dev/null +++ b/src/oauth/entities/access-token-result.entity.ts @@ -0,0 +1,9 @@ +export class AccessTokenResult { + access_token: string; + expires_in?: number; // 秒数 + refresh_token?: string; + refresh_token_expires_in?: number; // 秒数 + token_type?: string; + scope?: string; + [key: string]: any; +} diff --git a/src/oauth/index.ts b/src/oauth/index.ts new file mode 100644 index 0000000..092702c --- /dev/null +++ b/src/oauth/index.ts @@ -0,0 +1,2 @@ +export * from './oauth.module'; +export * from './oauth.service'; diff --git a/src/oauth/oauth.module.ts b/src/oauth/oauth.module.ts new file mode 100644 index 0000000..c1de96c --- /dev/null +++ b/src/oauth/oauth.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { OAuthService } from './oauth.service'; + +@Module({ + imports: [], + controllers: [], + providers: [OAuthService], + exports: [OAuthService], +}) +export class OAuthModule {} diff --git a/src/oauth/oauth.service.ts b/src/oauth/oauth.service.ts new file mode 100644 index 0000000..69d07df --- /dev/null +++ b/src/oauth/oauth.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; + +import { OAuthGetAccessTokenDto } from './dto/oauth.dto'; +import { AccessTokenResult } from './entities/access-token-result.entity'; + +@Injectable() +export class OAuthService { + async getAccessToken(url: string, dto: OAuthGetAccessTokenDto): Promise { + // POST 请求到 ProviderUrl 交换 access_token + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dto, (_, value) => (value === undefined ? undefined : value)), + }); + + const data = await response.json(); + + if (!data.access_token) { + throw new Error('Failed to get access token'); + } + console.log(data); + + return data; + } + + async getUserInfo(url: string, accessToken: string): Promise { + // 向 ProviderUrl 用户信息 API 发送 GET 请求 + const response = await fetch(url, { + method: 'GET', + headers: { + // 必须在请求头中传入 Authorization,格式为:Bearer + // Token type 目前只处理 bearer 类型 + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + // 如果请求失败,比如 token 无效或过期,处理错误 + if (!response.ok) { + console.error(`Failed to fetch user info: ${response.status} ${response.statusText}`); + return null; + } + + // 解析 JSON 返回的用户信息 + const userData = await response.json(); + return userData; + } +} diff --git a/src/redis/redis.module.ts b/src/redis/redis.module.ts index 9f91e31..451fbea 100644 --- a/src/redis/redis.module.ts +++ b/src/redis/redis.module.ts @@ -1,7 +1,7 @@ import { Global, Inject, Module } from '@nestjs/common'; import { createClient, createCluster, RedisClientType } from 'redis'; -import * as config from 'src/constants'; +import * as config from 'src/config'; @Global() @Module({ diff --git a/src/sms/sms.service.ts b/src/sms/sms.service.ts index d0f998c..a7dc2a2 100644 --- a/src/sms/sms.service.ts +++ b/src/sms/sms.service.ts @@ -1,7 +1,7 @@ import SMSClient from '@alicloud/sms-sdk'; import { Injectable } from '@nestjs/common'; -import * as config from 'src/constants'; +import * as config from 'src/config'; import { SendSmsDto } from './dto/send-sms.dto'; diff --git a/src/third-party/dto/bind-third-party.dto.ts b/src/third-party/dto/bind-third-party.dto.ts index 3e1bf03..859915d 100644 --- a/src/third-party/dto/bind-third-party.dto.ts +++ b/src/third-party/dto/bind-third-party.dto.ts @@ -1,6 +1,4 @@ -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; - -import { ThirdPartySource } from '../entities/third-party.entity'; +import { IsNotEmpty, IsString } from 'class-validator'; export class bindThirdPartyDto { @IsNotEmpty() @@ -12,8 +10,8 @@ export class bindThirdPartyDto { password: string; @IsNotEmpty() - @IsEnum(ThirdPartySource) - source: ThirdPartySource; + @IsString() + source: string; @IsNotEmpty() @IsString() diff --git a/src/third-party/entities/third-party.entity.ts b/src/third-party/entities/third-party.entity.ts index 92dc874..45f0429 100644 --- a/src/third-party/entities/third-party.entity.ts +++ b/src/third-party/entities/third-party.entity.ts @@ -1,32 +1,26 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { ApiProperty, IntersectionType } from '@nestjs/swagger'; -import { IsEnum, IsMongoId, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IntersectionType } from '@nestjs/swagger'; +import { IsMongoId, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { helper, MongoEntity } from 'src/mongo'; -export enum ThirdPartySource { - GITHUB = 'github', - WECHAT = 'wechat', -} - @Schema() export class ThirdPartyDoc { /** * 第三方登录来源 */ @IsNotEmpty() - @IsEnum(ThirdPartySource) - @ApiProperty({ enum: ThirdPartySource, enumName: 'ThirdPartySource' }) + @IsString() @Prop() - source: ThirdPartySource; + source: string; /** - * 第三方登录 id + * 第三方登录的用户唯一标识 */ @IsNotEmpty() @IsString() @Prop() - login: string; + tid: string; /** * 第三方登录 accessToken @@ -36,15 +30,35 @@ export class ThirdPartyDoc { @Prop() accessToken: string; + /** + * 第三方登录过期时间 + */ + @IsOptional() + @Prop() + expireAt?: number; + + /** + * 第三方登录 token 类型 + */ @IsOptional() @IsString() @Prop() - avatar?: string; + tokenType?: string; + /** + * 第三方登录 refreshToken + */ @IsOptional() @IsString() @Prop() - name?: string; + refreshToken?: string; + + /** + * 第三方登录 refreshToken 过期时间 + */ + @IsOptional() + @Prop() + refreshTokenExpireAt?: number; /** * 关联uid @@ -53,8 +67,18 @@ export class ThirdPartyDoc { @IsMongoId() @Prop() uid?: string; + + /** + * 用于存储第三方的额外数据,应用自己选择 + */ + @IsOptional() + @IsString() + @Prop() + data?: string; } export const ThirdPartySchema = helper(SchemaFactory.createForClass(ThirdPartyDoc)); export class ThirdParty extends IntersectionType(ThirdPartyDoc, MongoEntity) {} export type ThirdPartyDocument = ThirdPartyDoc & Document; + +ThirdPartySchema.index({ source: 1, tid: 1 }, { unique: true }); diff --git a/src/third-party/third-party.service.ts b/src/third-party/third-party.service.ts index 7ddd35e..7dc19d4 100644 --- a/src/third-party/third-party.service.ts +++ b/src/third-party/third-party.service.ts @@ -7,7 +7,7 @@ import { buildMongooseQuery } from 'src/mongo'; import { createThirdPartyDto } from './dto/create-third-party.dto'; import { ListThirdPartyDto } from './dto/list-third-party.dto'; import { UpdateThirdPartyDto } from './dto/update-third-party.dto'; -import { ThirdParty, ThirdPartyDocument, ThirdPartySource } from './entities/third-party.entity'; +import { ThirdParty, ThirdPartyDocument } from './entities/third-party.entity'; @Injectable() export class ThirdPartyService { @@ -38,17 +38,17 @@ export class ThirdPartyService { return this.thirdPartyModel.findByIdAndUpdate(id, updateDto, { new: true }).exec(); } - findBySource(login: string, source: ThirdPartySource) { - return this.thirdPartyModel.findOne({ login, source }).exec(); + findBySource(login: string, source: string) { + return this.thirdPartyModel.findOne({ tid: login, source }).exec(); } - upsert(login: string, source: ThirdPartySource, dto: createThirdPartyDto) { + upsert(login: string, source: string, dto: createThirdPartyDto) { return this.thirdPartyModel - .findOneAndUpdate({ login, source }, dto, { upsert: true, new: true }) + .findOneAndUpdate({ tid: login, source }, dto, { upsert: true, new: true }) .exec(); } - findAndUpdate(login: string, source: ThirdPartySource, dto: UpdateThirdPartyDto) { - return this.thirdPartyModel.findOneAndUpdate({ login, source }, dto, { new: true }).exec(); + findAndUpdate(login: string, source: string, dto: UpdateThirdPartyDto) { + return this.thirdPartyModel.findOneAndUpdate({ tid: login, source }, dto, { new: true }).exec(); } } diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index c479cef..1dc1329 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -30,7 +30,7 @@ import { Response } from 'express'; import { RedisClientType } from 'redis'; import { SetCacheInterceptor, UnsetCacheInterceptor } from 'src/common'; -import * as config from 'src/constants'; +import { ErrorCodes } from 'src/constants'; import { NamespaceService } from 'src/namespace'; import { CreateUserDto } from './dto/create-user.dto'; @@ -67,7 +67,7 @@ export class UserController { const user = await this.userService.findByUsername(username); if (user) { throw new ConflictException({ - code: config.ErrorCodes.USER_ALREADY_EXISTS, + code: ErrorCodes.USER_ALREADY_EXISTS, message: `Username ${username} already exists.`, details: [ { @@ -83,7 +83,7 @@ export class UserController { const user = await this.userService.findByEmployeeId(employeeId); if (user) { throw new ConflictException({ - code: config.ErrorCodes.EMPLOYEE_ID_ALREADY_EXISTS, + code: ErrorCodes.EMPLOYEE_ID_ALREADY_EXISTS, message: `EmployeeId ${employeeId} already exists.`, details: [ { @@ -99,7 +99,7 @@ export class UserController { const user = await this.userService.findByEmail(email); if (user) { throw new ConflictException({ - code: config.ErrorCodes.EMAIL_ALREADY_EXISTS, + code: ErrorCodes.EMAIL_ALREADY_EXISTS, message: `Email ${email} already exists.`, details: [ { @@ -115,7 +115,7 @@ export class UserController { const user = await this.userService.findByPhone(phone); if (user) { throw new ConflictException({ - code: config.ErrorCodes.PHONE_ALREADY_EXISTS, + code: ErrorCodes.PHONE_ALREADY_EXISTS, message: `Phone ${phone} already exists.`, details: [ { @@ -132,7 +132,7 @@ export class UserController { const namespace = await this.namespaceService.getByKey(ns); if (!namespace) { throw new NotFoundException({ - code: config.ErrorCodes.NAMESPACE_NOT_FOUND, + code: ErrorCodes.NAMESPACE_NOT_FOUND, message: `Namespace ${ns} not found.`, details: [ { @@ -184,7 +184,7 @@ export class UserController { const user = await this.userService.get(userId); if (!user) { throw new NotFoundException({ - code: config.ErrorCodes.USER_NOT_FOUND, + code: ErrorCodes.USER_NOT_FOUND, message: `User ${userId} not found.`, }); } @@ -211,7 +211,7 @@ export class UserController { const namespace = await this.namespaceService.getByKey(ns); if (!namespace) { throw new NotFoundException({ - code: config.ErrorCodes.NAMESPACE_NOT_FOUND, + code: ErrorCodes.NAMESPACE_NOT_FOUND, message: `Namespace ${ns} not found.`, details: [ { @@ -229,7 +229,7 @@ export class UserController { const exists = await this.userService.findByUsername(username); if (exists && exists.id !== user?.id) { throw new ConflictException({ - code: config.ErrorCodes.USER_ALREADY_EXISTS, + code: ErrorCodes.USER_ALREADY_EXISTS, message: `Username ${username} already exists.`, details: [ { @@ -245,7 +245,7 @@ export class UserController { const exists = await this.userService.findByEmployeeId(employeeId); if (exists && exists.id !== user?.id) { throw new ConflictException({ - code: config.ErrorCodes.EMPLOYEE_ID_ALREADY_EXISTS, + code: ErrorCodes.EMPLOYEE_ID_ALREADY_EXISTS, message: `EmployeeId ${employeeId} already exists.`, details: [ { @@ -261,7 +261,7 @@ export class UserController { const exists = await this.userService.findByEmail(email); if (exists && exists.id !== user?.id) { throw new ConflictException({ - code: config.ErrorCodes.EMAIL_ALREADY_EXISTS, + code: ErrorCodes.EMAIL_ALREADY_EXISTS, message: `Email ${email} already exists.`, details: [ { @@ -277,7 +277,7 @@ export class UserController { const exists = await this.userService.findByPhone(phone); if (exists && exists.id !== user?.id) { throw new ConflictException({ - code: config.ErrorCodes.PHONE_ALREADY_EXISTS, + code: ErrorCodes.PHONE_ALREADY_EXISTS, message: `Phone ${phone} already exists.`, details: [ { @@ -312,7 +312,7 @@ export class UserController { const namespace = await this.namespaceService.getByKey(ns); if (!namespace) { throw new NotFoundException({ - code: config.ErrorCodes.NAMESPACE_NOT_FOUND, + code: ErrorCodes.NAMESPACE_NOT_FOUND, message: `Namespace ${ns} not found.`, details: [ { @@ -330,7 +330,7 @@ export class UserController { const exists = await this.userService.findByUsername(username); if (exists && exists.id !== user?.id) { throw new ConflictException({ - code: config.ErrorCodes.USER_ALREADY_EXISTS, + code: ErrorCodes.USER_ALREADY_EXISTS, message: `Username ${username} already exists.`, details: [ { @@ -346,7 +346,7 @@ export class UserController { const exists = await this.userService.findByEmployeeId(toBeUpdatedEmployeeId); if (exists && exists.id !== user?.id) { throw new ConflictException({ - code: config.ErrorCodes.EMPLOYEE_ID_ALREADY_EXISTS, + code: ErrorCodes.EMPLOYEE_ID_ALREADY_EXISTS, message: `EmployeeId ${employeeId} already exists.`, details: [ { @@ -362,7 +362,7 @@ export class UserController { const exists = await this.userService.findByEmail(email); if (exists && exists.id !== user?.id) { throw new ConflictException({ - code: config.ErrorCodes.EMAIL_ALREADY_EXISTS, + code: ErrorCodes.EMAIL_ALREADY_EXISTS, message: `Email ${email} already exists.`, details: [ { @@ -378,7 +378,7 @@ export class UserController { const exists = await this.userService.findByPhone(phone); if (exists && exists.id !== user?.id) { throw new ConflictException({ - code: config.ErrorCodes.PHONE_ALREADY_EXISTS, + code: ErrorCodes.PHONE_ALREADY_EXISTS, message: `Phone ${phone} already exists.`, details: [ { @@ -476,7 +476,7 @@ export class UserController { const user = await this.userService.get(userId); if (!user) { throw new NotFoundException({ - code: config.ErrorCodes.USER_NOT_FOUND, + code: ErrorCodes.USER_NOT_FOUND, message: `User ${userId} not found.`, }); } @@ -504,14 +504,14 @@ export class UserController { const user = await this.userService.get(userId); if (!user) { throw new NotFoundException({ - code: config.ErrorCodes.USER_NOT_FOUND, + code: ErrorCodes.USER_NOT_FOUND, message: `User ${userId} not found.`, }); } if (user.password && !this.userService.checkPassword(user.password, dto.oldPassword)) { throw new BadRequestException({ - code: config.ErrorCodes.WRONG_OLD_PASSWORD, + code: ErrorCodes.WRONG_OLD_PASSWORD, message: 'Old password not match.', }); } diff --git a/src/user/verify-identity.ts b/src/user/verify-identity.ts index a5dcd7f..a972783 100644 --- a/src/user/verify-identity.ts +++ b/src/user/verify-identity.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { get } from 'lodash'; -import * as config from 'src/constants'; +import * as config from 'src/config'; const results = { 400: '400 参数不能为空', diff --git a/test/auth-login-logout.e2e-spec.ts b/test/auth-login-logout.e2e-spec.ts index 8293dcd..81df3e6 100644 --- a/test/auth-login-logout.e2e-spec.ts +++ b/test/auth-login-logout.e2e-spec.ts @@ -6,7 +6,7 @@ import { Connection } from 'mongoose'; import request from 'supertest'; import { SessionWithToken } from 'src/auth'; -import { auth } from 'src/constants'; +import { auth } from 'src/config'; import { NamespaceService } from 'src/namespace'; import { UserService } from 'src/user'; diff --git a/test/captcha.e2e-spec.ts b/test/captcha.e2e-spec.ts index f5ca202..21683f7 100644 --- a/test/captcha.e2e-spec.ts +++ b/test/captcha.e2e-spec.ts @@ -7,7 +7,7 @@ import request from 'supertest'; import { SessionWithToken } from 'src/auth'; import { CaptchaService, CreateCaptchaDto } from 'src/captcha'; -import { auth } from 'src/constants'; +import { auth } from 'src/config'; import { MongoErrorsInterceptor } from 'src/mongo'; import { AppModule } from '../src/app.module'; diff --git a/test/namespace.e2e-spec.ts b/test/namespace.e2e-spec.ts index a6970c4..94cad3c 100644 --- a/test/namespace.e2e-spec.ts +++ b/test/namespace.e2e-spec.ts @@ -5,7 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Connection, Model } from 'mongoose'; import request from 'supertest'; -import { auth } from 'src/constants'; +import { auth } from 'src/config'; import { MongoErrorsInterceptor } from 'src/mongo'; import { Namespace, NamespaceDocument, NamespaceService } from 'src/namespace'; diff --git a/test/user.e2e-spec.ts b/test/user.e2e-spec.ts index 0f59571..3f426d7 100644 --- a/test/user.e2e-spec.ts +++ b/test/user.e2e-spec.ts @@ -5,7 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Connection } from 'mongoose'; import request from 'supertest'; -import { auth } from 'src/constants'; +import { auth } from 'src/config'; import { MongoErrorsInterceptor } from 'src/mongo'; import { NamespaceService } from 'src/namespace'; import { UserService } from 'src/user'; From 7dce27d1f0093d03f88219ec8fd2c4c6a2a73fe6 Mon Sep 17 00:00:00 2001 From: zzswang Date: Tue, 25 Feb 2025 19:02:21 +0800 Subject: [PATCH 02/12] fix: test --- src/common/validate.test.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/common/validate.test.spec.ts b/src/common/validate.test.spec.ts index 1c748aa..d090c89 100644 --- a/src/common/validate.test.spec.ts +++ b/src/common/validate.test.spec.ts @@ -11,7 +11,11 @@ describe('common validate', () => { // 30 个字符 expect(isNs('abcdefghijklmnopqrstuvwxyz01234')).toBe(true); - // 31 个字符 - expect(isNs('abcdefghijklmnopqrstuvwxyz012345')).toBe(false); + // 210 个字符 + expect( + isNs( + 'abcdefghijklmnopqrstuvwxyz01234abcdefghijklmnopqrstuvwxyz01234abcdefghijklmnopqrstuvwxyz01234abcdefghijklmnopqrstuvwxyz01234abcdefghijklmnopqrstuvwxyz01234abcdefghijklmnopqrstuvwxyz01234abcdefghijklmnopqrstuvwxyz01234' + ) + ).toBe(false); }); }); From b90563c1ce8f77133a154d89701b5717bc944649 Mon Sep 17 00:00:00 2001 From: zzswang Date: Wed, 26 Feb 2025 11:17:45 +0800 Subject: [PATCH 03/12] refactor: authorize url --- .env | 4 ++ openapi.json | 47 +++++++++++++++++++-- src/auth/auth.module.ts | 20 ++++++--- src/config/config.ts | 15 ------- src/third-party/dto/authorize-query.dto.ts | 15 +++++++ src/third-party/dto/bind-third-party.dto.ts | 2 +- src/third-party/third-party.controller.ts | 29 ++++++++++++- src/third-party/third-party.service.ts | 12 +++--- 8 files changed, 113 insertions(+), 31 deletions(-) create mode 100644 src/third-party/dto/authorize-query.dto.ts diff --git a/.env b/.env index 371ae2c..a5391fa 100644 --- a/.env +++ b/.env @@ -36,3 +36,7 @@ API_KEY=YHImpSchS4iEwVD1IxXp4012 GITHUB_CLIENT_ID=Iv23lizBaVPIiABBCHaz GITHUB_CLIENT_SECRET=041f46399c1396ec27d16851c1aa2aa479a3f5a5 +GITHUB_AUTHORIZE_URL=https://github.com/login/oauth/authorize +GITHUB_ACCESS_TOKEN_URL=https://github.com/login/oauth/access_token +GITHUB_USER_INFO_URL=https://api.github.com/user +GITHUB_TID_FIELD=login diff --git a/openapi.json b/openapi.json index 335815d..a2e56dd 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "6facc3b7e3f1115a6e7ef21e2b97f6c272443477fb0f7f78797a8f2cd83bd3d6", + "hash": "7b5288112cbe3826662cb8686eda651cd998bf0b0e7e5ef8b40022612293f8a7", "openapi": "3.0.0", "paths": { "/hello": { @@ -3014,6 +3014,47 @@ ] } }, + "/third-parties/@authorize": { + "get": { + "operationId": "authorizeThirdParty", + "summary": "", + "description": "Redirect to OAuth provider's authorization page", + "parameters": [ + { + "name": "provider", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "redirect_uri", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "state", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "thirdParty" + ] + } + }, "/industries": { "get": { "operationId": "listIndustries", @@ -5178,7 +5219,7 @@ "source": { "type": "string" }, - "login": { + "tid": { "type": "string" } }, @@ -5186,7 +5227,7 @@ "username", "password", "source", - "login" + "tid" ] }, "Industry": { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 1684fc6..867e78f 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -22,11 +22,21 @@ import { AuthService } from './auth.service'; imports: [ JwtModule.register({ global: true, - secretOrPrivateKey: config.auth.jwtSecretKey ?? fs.readFileSync('ssl/private.key', 'utf-8'), - signOptions: { - allowInsecureKeySizes: true, - algorithm: config.auth.jwtSecretKey ? 'HS256' : 'RS256', - }, + ...(config.auth.jwtSecretKey + ? { + secret: config.auth.jwtSecretKey, + signOptions: { + allowInsecureKeySizes: true, + algorithm: 'HS256', + }, + } + : { + privateKey: fs.readFileSync('ssl/private.key', 'utf-8'), + signOptions: { + allowInsecureKeySizes: true, + algorithm: 'RS256', + }, + }), }), UserModule, SessionModule, diff --git a/src/config/config.ts b/src/config/config.ts index 1c9e587..35377e8 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -70,18 +70,3 @@ export const oauthProvider = { userInfoUrl: (provider: string) => loadEnv(`${toUpperSnakeCase(provider)}_USER_INFO_URL`), tidField: (provider: string) => loadEnv(`${toUpperSnakeCase(provider)}_TID_FIELD`), // 第三方登录的用户唯一标识字段 }; - -export const github = { - clientId: loadEnv('GITHUB_CLIENT_ID'), - clientSecret: loadEnv('GITHUB_CLIENT_SECRET'), - authorizeUrl: loadEnv('GITHUB_AUTHORIZE_URL', { - default: 'https://github.com/login/oauth/authorize', - }), - accessTokenUrl: loadEnv('GITHUB_ACCESS_TOKEN_URL', { - default: 'https://github.com/login/oauth/access_token', - }), - userInfoUrl: loadEnv('GITHUB_USER_INFO_URL', { - default: 'https://api.github.com/user', - }), - tidField: loadEnv('GITHUB_TID_FIELD', { default: 'login' }), // 第三方登录的用户唯一标识字段 -}; diff --git a/src/third-party/dto/authorize-query.dto.ts b/src/third-party/dto/authorize-query.dto.ts new file mode 100644 index 0000000..5a21979 --- /dev/null +++ b/src/third-party/dto/authorize-query.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class AuthorizeQueryDto { + @IsNotEmpty() + @IsString() + provider: string; + + @IsOptional() + @IsString() + redirect_uri?: string; + + @IsOptional() + @IsString() + state?: string; +} diff --git a/src/third-party/dto/bind-third-party.dto.ts b/src/third-party/dto/bind-third-party.dto.ts index 859915d..16c0de5 100644 --- a/src/third-party/dto/bind-third-party.dto.ts +++ b/src/third-party/dto/bind-third-party.dto.ts @@ -15,5 +15,5 @@ export class bindThirdPartyDto { @IsNotEmpty() @IsString() - login: string; + tid: string; } diff --git a/src/third-party/third-party.controller.ts b/src/third-party/third-party.controller.ts index f21966a..91d6d5a 100644 --- a/src/third-party/third-party.controller.ts +++ b/src/third-party/third-party.controller.ts @@ -8,14 +8,17 @@ import { Patch, Post, Query, + Redirect, Res, } from '@nestjs/common'; import { ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; +import * as config from 'src/config'; import { ErrorCodes } from 'src/constants'; import { UserService } from 'src/user'; +import { AuthorizeQueryDto } from './dto/authorize-query.dto'; import { bindThirdPartyDto } from './dto/bind-third-party.dto'; import { createThirdPartyDto } from './dto/create-third-party.dto'; import { ListThirdPartyDto } from './dto/list-third-party.dto'; @@ -124,6 +127,30 @@ export class ThirdPartyController { }); } - return this.thirdPartyService.findAndUpdate(bindDto.login, bindDto.source, { uid: user.id }); + return this.thirdPartyService.findAndUpdate(bindDto.tid, bindDto.source, { uid: user.id }); + } + + /** + * Redirect to OAuth provider's authorization page + */ + @ApiOperation({ operationId: 'authorizeThirdParty' }) + @Get('@authorize') + @Redirect() + async authorize(@Query() query: AuthorizeQueryDto) { + const { provider, redirect_uri, state } = query; + const clientId = config.oauthProvider.clientId(provider); + const authorizeUrl = config.oauthProvider.authorizeUrl(provider); + + const params = new URLSearchParams({ + client_id: clientId, + response_type: 'code', + ...(redirect_uri && { redirect_uri }), + ...(state && { state }), + }); + + return { + url: `${authorizeUrl}?${params.toString()}`, + statusCode: 302, + }; } } diff --git a/src/third-party/third-party.service.ts b/src/third-party/third-party.service.ts index 7dc19d4..6f985b3 100644 --- a/src/third-party/third-party.service.ts +++ b/src/third-party/third-party.service.ts @@ -38,17 +38,17 @@ export class ThirdPartyService { return this.thirdPartyModel.findByIdAndUpdate(id, updateDto, { new: true }).exec(); } - findBySource(login: string, source: string) { - return this.thirdPartyModel.findOne({ tid: login, source }).exec(); + findBySource(tid: string, source: string) { + return this.thirdPartyModel.findOne({ tid: tid, source }).exec(); } - upsert(login: string, source: string, dto: createThirdPartyDto) { + upsert(tid: string, source: string, dto: createThirdPartyDto) { return this.thirdPartyModel - .findOneAndUpdate({ tid: login, source }, dto, { upsert: true, new: true }) + .findOneAndUpdate({ tid: tid, source }, dto, { upsert: true, new: true }) .exec(); } - findAndUpdate(login: string, source: string, dto: UpdateThirdPartyDto) { - return this.thirdPartyModel.findOneAndUpdate({ tid: login, source }, dto, { new: true }).exec(); + findAndUpdate(tid: string, source: string, dto: UpdateThirdPartyDto) { + return this.thirdPartyModel.findOneAndUpdate({ tid: tid, source }, dto, { new: true }).exec(); } } From 71eee8a848eee00600d93e0c12b49463b55f6a81 Mon Sep 17 00:00:00 2001 From: zzswang Date: Wed, 26 Feb 2025 17:00:50 +0800 Subject: [PATCH 04/12] refactor: thirdparty data --- openapi.json | 14 ++++++++------ src/third-party/entities/third-party.entity.ts | 6 +++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/openapi.json b/openapi.json index a2e56dd..01db541 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "7b5288112cbe3826662cb8686eda651cd998bf0b0e7e5ef8b40022612293f8a7", + "hash": "e06f122818d2bc9da1f74bf9add35f76c7a10cbc284a77b8e5f71e6d32fcd958", "openapi": "3.0.0", "paths": { "/hello": { @@ -2832,7 +2832,7 @@ "name": "data", "required": false, "in": "query", - "description": "用于存储第三方的额外数据,应用自己选择", + "description": "用于存储第三方的额外数据", "schema": { "type": "string" } @@ -5088,13 +5088,14 @@ }, "data": { "type": "string", - "description": "用于存储第三方的额外数据,应用自己选择" + "description": "用于存储第三方的额外数据" } }, "required": [ "source", "tid", - "accessToken" + "accessToken", + "data" ] }, "ThirdParty": { @@ -5134,7 +5135,7 @@ }, "data": { "type": "string", - "description": "用于存储第三方的额外数据,应用自己选择" + "description": "用于存储第三方的额外数据" }, "id": { "type": "string", @@ -5163,6 +5164,7 @@ "source", "tid", "accessToken", + "data", "id" ] }, @@ -5203,7 +5205,7 @@ }, "data": { "type": "string", - "description": "用于存储第三方的额外数据,应用自己选择" + "description": "用于存储第三方的额外数据" } } }, diff --git a/src/third-party/entities/third-party.entity.ts b/src/third-party/entities/third-party.entity.ts index 45f0429..d841aa2 100644 --- a/src/third-party/entities/third-party.entity.ts +++ b/src/third-party/entities/third-party.entity.ts @@ -69,12 +69,12 @@ export class ThirdPartyDoc { uid?: string; /** - * 用于存储第三方的额外数据,应用自己选择 + * 用于存储第三方的额外数据 */ - @IsOptional() + @IsNotEmpty() @IsString() @Prop() - data?: string; + data: string; } export const ThirdPartySchema = helper(SchemaFactory.createForClass(ThirdPartyDoc)); From d2403da1be894e3b2a2d4243fb282c5d0cefb73a Mon Sep 17 00:00:00 2001 From: zzswang Date: Wed, 26 Feb 2025 20:25:04 +0800 Subject: [PATCH 05/12] refactor: get authorizer --- openapi.json | 102 ++++++++++-------- src/auth/auth.controller.ts | 23 ++++ .../dto/authorize-query.dto.ts | 2 +- src/auth/entities/authorizer.entity.ts | 10 ++ src/third-party/third-party.controller.ts | 27 ----- 5 files changed, 94 insertions(+), 70 deletions(-) rename src/{third-party => auth}/dto/authorize-query.dto.ts (86%) create mode 100644 src/auth/entities/authorizer.entity.ts diff --git a/openapi.json b/openapi.json index 01db541..c336b47 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "e06f122818d2bc9da1f74bf9add35f76c7a10cbc284a77b8e5f71e6d32fcd958", + "hash": "641a7ca5b834c3b4303f21ad83a13cc884ff4845760d6b7ddaab38e94735542c", "openapi": "3.0.0", "paths": { "/hello": { @@ -81,6 +81,53 @@ ] } }, + "/auth/getAuthorizer": { + "get": { + "operationId": "getAuthorizer", + "summary": "", + "parameters": [ + { + "name": "provider", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "redirect_uri", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "state", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Authorizer" + } + } + } + } + }, + "tags": [ + "auth" + ] + } + }, "/auth/@loginByGithub": { "post": { "operationId": "loginByGithub", @@ -3014,47 +3061,6 @@ ] } }, - "/third-parties/@authorize": { - "get": { - "operationId": "authorizeThirdParty", - "summary": "", - "description": "Redirect to OAuth provider's authorization page", - "parameters": [ - { - "name": "provider", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "redirect_uri", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "state", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "tags": [ - "thirdParty" - ] - } - }, "/industries": { "get": { "operationId": "listIndustries", @@ -3484,6 +3490,18 @@ "tokenExpireAt" ] }, + "Authorizer": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "url" + } + }, + "required": [ + "url" + ] + }, "GithubDto": { "type": "object", "properties": { diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 47603a1..c3ccecb 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -4,10 +4,12 @@ import { ConflictException, Controller, ForbiddenException, + Get, HttpCode, HttpStatus, NotFoundException, Post, + Query, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; @@ -25,6 +27,7 @@ import { ThirdPartyDoc, ThirdPartyService } from 'src/third-party'; import { User, UserDocument, UserService } from 'src/user'; import { AuthService } from './auth.service'; +import { GetAuthorizerQuery } from './dto/authorize-query.dto'; import { GithubDto } from './dto/github.dto'; import { LoginByEmailDto, LoginByPhoneDto, LoginDto, LogoutDto } from './dto/login.dto'; import { OAuthDto } from './dto/oauth.dto'; @@ -32,6 +35,7 @@ import { RefreshTokenDto } from './dto/refresh-token.dto'; import { RegisterByEmailDto, RegisterbyPhoneDto, RegisterDto } from './dto/register.dto'; import { ResetPasswordByEmailDto, ResetPasswordByPhoneDto } from './dto/reset-password.dto'; import { SignTokenDto } from './dto/sign-token.dto'; +import { Authorizer } from './entities/authorizer.entity'; import { SessionWithToken, Token } from './entities/session-with-token.entity'; const SESSION_EXPIRES_IN = '7d'; @@ -147,6 +151,25 @@ export class AuthController { return this._login(user); } + @ApiOperation({ operationId: 'getAuthorizer' }) + @Get('getAuthorizer') + authorize(@Query() query: GetAuthorizerQuery): Authorizer { + const { provider, redirect_uri, state } = query; + const clientId = config.oauthProvider.clientId(provider); + const authorizeUrl = config.oauthProvider.authorizeUrl(provider); + + const params = new URLSearchParams({ + client_id: clientId, + response_type: 'code', + ...(redirect_uri && { redirect_uri }), + ...(state && { state }), + }); + + return { + url: `${authorizeUrl}?${params.toString()}`, + }; + } + /** * login by Github */ diff --git a/src/third-party/dto/authorize-query.dto.ts b/src/auth/dto/authorize-query.dto.ts similarity index 86% rename from src/third-party/dto/authorize-query.dto.ts rename to src/auth/dto/authorize-query.dto.ts index 5a21979..5c9b875 100644 --- a/src/third-party/dto/authorize-query.dto.ts +++ b/src/auth/dto/authorize-query.dto.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; -export class AuthorizeQueryDto { +export class GetAuthorizerQuery { @IsNotEmpty() @IsString() provider: string; diff --git a/src/auth/entities/authorizer.entity.ts b/src/auth/entities/authorizer.entity.ts new file mode 100644 index 0000000..016c931 --- /dev/null +++ b/src/auth/entities/authorizer.entity.ts @@ -0,0 +1,10 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class Authorizer { + /** + * url + */ + @IsNotEmpty() + @IsString() + url: string; +} diff --git a/src/third-party/third-party.controller.ts b/src/third-party/third-party.controller.ts index 91d6d5a..763f940 100644 --- a/src/third-party/third-party.controller.ts +++ b/src/third-party/third-party.controller.ts @@ -8,17 +8,14 @@ import { Patch, Post, Query, - Redirect, Res, } from '@nestjs/common'; import { ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; -import * as config from 'src/config'; import { ErrorCodes } from 'src/constants'; import { UserService } from 'src/user'; -import { AuthorizeQueryDto } from './dto/authorize-query.dto'; import { bindThirdPartyDto } from './dto/bind-third-party.dto'; import { createThirdPartyDto } from './dto/create-third-party.dto'; import { ListThirdPartyDto } from './dto/list-third-party.dto'; @@ -129,28 +126,4 @@ export class ThirdPartyController { return this.thirdPartyService.findAndUpdate(bindDto.tid, bindDto.source, { uid: user.id }); } - - /** - * Redirect to OAuth provider's authorization page - */ - @ApiOperation({ operationId: 'authorizeThirdParty' }) - @Get('@authorize') - @Redirect() - async authorize(@Query() query: AuthorizeQueryDto) { - const { provider, redirect_uri, state } = query; - const clientId = config.oauthProvider.clientId(provider); - const authorizeUrl = config.oauthProvider.authorizeUrl(provider); - - const params = new URLSearchParams({ - client_id: clientId, - response_type: 'code', - ...(redirect_uri && { redirect_uri }), - ...(state && { state }), - }); - - return { - url: `${authorizeUrl}?${params.toString()}`, - statusCode: 302, - }; - } } From 8137d9713af25d18b5c44f01d518ccafa2980456 Mon Sep 17 00:00:00 2001 From: zzswang Date: Wed, 26 Feb 2025 20:25:51 +0800 Subject: [PATCH 06/12] refactor: get authorizer --- openapi.json | 4 ++-- src/auth/auth.controller.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openapi.json b/openapi.json index c336b47..bd4dd37 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "641a7ca5b834c3b4303f21ad83a13cc884ff4845760d6b7ddaab38e94735542c", + "hash": "a86c5aed7da5c08f49d44dcd71f0716ad84295effcbe577e730fbe80936d982a", "openapi": "3.0.0", "paths": { "/hello": { @@ -81,7 +81,7 @@ ] } }, - "/auth/getAuthorizer": { + "/auth/authorizer": { "get": { "operationId": "getAuthorizer", "summary": "", diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index c3ccecb..670e774 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -152,7 +152,7 @@ export class AuthController { } @ApiOperation({ operationId: 'getAuthorizer' }) - @Get('getAuthorizer') + @Get('authorizer') authorize(@Query() query: GetAuthorizerQuery): Authorizer { const { provider, redirect_uri, state } = query; const clientId = config.oauthProvider.clientId(provider); From 7f154e1a5f0db1c23d562fc1041a89f36a94ee01 Mon Sep 17 00:00:00 2001 From: zzswang Date: Thu, 27 Feb 2025 16:01:18 +0800 Subject: [PATCH 07/12] refactor: logout by session id --- openapi.json | 8 ++++---- src/auth/auth.controller.ts | 4 ++-- src/auth/dto/login.dto.ts | 4 ++-- src/oauth/oauth.service.ts | 1 - 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/openapi.json b/openapi.json index bd4dd37..83e4212 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "a86c5aed7da5c08f49d44dcd71f0716ad84295effcbe577e730fbe80936d982a", + "hash": "257e41a7e0d78551ca4de97e32f08ddcd7cb57ada92a4af34daf397ab9a01914", "openapi": "3.0.0", "paths": { "/hello": { @@ -3590,13 +3590,13 @@ "LogoutDto": { "type": "object", "properties": { - "refreshToken": { + "sid": { "type": "string", - "description": "session refreshToken" + "description": "session id" } }, "required": [ - "refreshToken" + "sid" ] }, "RegisterDto": { diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 670e774..04a96d8 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -153,7 +153,7 @@ export class AuthController { @ApiOperation({ operationId: 'getAuthorizer' }) @Get('authorizer') - authorize(@Query() query: GetAuthorizerQuery): Authorizer { + getAuthorizer(@Query() query: GetAuthorizerQuery): Authorizer { const { provider, redirect_uri, state } = query; const clientId = config.oauthProvider.clientId(provider); const authorizeUrl = config.oauthProvider.authorizeUrl(provider); @@ -303,7 +303,7 @@ export class AuthController { @HttpCode(HttpStatus.NO_CONTENT) @Post('@logout') async logout(@Body() dto: LogoutDto): Promise { - await this.sessionService.deleteByRefreshToken(dto.refreshToken); + await this.sessionService.delete(dto.sid); } /** diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index a666320..4b9836e 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -65,9 +65,9 @@ export class LoginByEmailDto { export class LogoutDto { /** - * session refreshToken + * session id */ @IsNotEmpty() @IsString() - refreshToken: string; + sid: string; } diff --git a/src/oauth/oauth.service.ts b/src/oauth/oauth.service.ts index 69d07df..0050d45 100644 --- a/src/oauth/oauth.service.ts +++ b/src/oauth/oauth.service.ts @@ -21,7 +21,6 @@ export class OAuthService { if (!data.access_token) { throw new Error('Failed to get access token'); } - console.log(data); return data; } From 9ce319b0871e127da01d2eba86b87fcc93a507e0 Mon Sep 17 00:00:00 2001 From: zzswang Date: Thu, 27 Feb 2025 21:08:23 +0800 Subject: [PATCH 08/12] refactor: session subject and source --- openapi.json | 37 +++++++++++++++++++++----- src/auth/auth.controller.ts | 13 +++++---- src/auth/entities/jwt.entity.ts | 1 + src/session/entities/session.entity.ts | 13 ++++++--- test/auth-login-logout.e2e-spec.ts | 2 +- 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/openapi.json b/openapi.json index 83e4212..dbf9260 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "257e41a7e0d78551ca4de97e32f08ddcd7cb57ada92a4af34daf397ab9a01914", + "hash": "cafc0fb323e45f7e54fec5b8b1f6c2f3c8f7bf57bc03c88ed40c3ae406e0dee9", "openapi": "3.0.0", "paths": { "/hello": { @@ -1471,7 +1471,16 @@ "name": "subject", "required": false, "in": "query", - "description": "用户或第三方用户\n\"user|123456789\"\n\"github|123456789\"\n\"client|abcddfe\"", + "description": "用户或第三方用户 id", + "schema": { + "type": "string" + } + }, + { + "name": "source", + "required": false, + "in": "query", + "description": "如果来自第三方,则会加上 source", "schema": { "type": "string" } @@ -3425,7 +3434,11 @@ }, "subject": { "type": "string", - "description": "用户或第三方用户\n\"user|123456789\"\n\"github|123456789\"\n\"client|abcddfe\"" + "description": "用户或第三方用户 id" + }, + "source": { + "type": "string", + "description": "如果来自第三方,则会加上 source" }, "permissions": { "description": "受限权限,如果提供这个字段,会覆盖用户的权限", @@ -4401,7 +4414,11 @@ }, "subject": { "type": "string", - "description": "用户或第三方用户\n\"user|123456789\"\n\"github|123456789\"\n\"client|abcddfe\"" + "description": "用户或第三方用户 id" + }, + "source": { + "type": "string", + "description": "如果来自第三方,则会加上 source" }, "permissions": { "description": "受限权限,如果提供这个字段,会覆盖用户的权限", @@ -4445,7 +4462,11 @@ }, "subject": { "type": "string", - "description": "用户或第三方用户\n\"user|123456789\"\n\"github|123456789\"\n\"client|abcddfe\"" + "description": "用户或第三方用户 id" + }, + "source": { + "type": "string", + "description": "如果来自第三方,则会加上 source" }, "permissions": { "description": "受限权限,如果提供这个字段,会覆盖用户的权限", @@ -4509,7 +4530,11 @@ }, "subject": { "type": "string", - "description": "用户或第三方用户\n\"user|123456789\"\n\"github|123456789\"\n\"client|abcddfe\"" + "description": "用户或第三方用户 id" + }, + "source": { + "type": "string", + "description": "如果来自第三方,则会加上 source" }, "permissions": { "description": "受限权限,如果提供这个字段,会覆盖用户的权限", diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 04a96d8..bf3c128 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -56,7 +56,7 @@ export class AuthController { _login = async (user: UserDocument): Promise => { const session = await this.sessionService.create({ - subject: `user|${user.id}`, + subject: user.id, ns: user.ns, groups: user.groups, type: user.type, @@ -73,7 +73,7 @@ export class AuthController { const tokenExpireAt = addShortTimeSpan(TOKEN_EXPIRES_IN); const token = this.jwtService.sign(jwtpayload, { expiresIn: TOKEN_EXPIRES_IN, - subject: `user|${user.id}`, + subject: user.id, }); const res: SessionWithToken = { @@ -90,14 +90,16 @@ export class AuthController { }; _loginByThirdParty = async (thirdParty: ThirdPartyDoc): Promise => { - const subject = `${thirdParty.source}|${thirdParty.tid}`; + const subject = thirdParty.tid; const session = await this.sessionService.create({ subject, + source: thirdParty.source, refreshTokenExpireAt: addShortTimeSpan(SESSION_EXPIRES_IN), // session 先固定 7 天过期吧 }); const jwtpayload: JwtPayload = { sid: session.id, + source: thirdParty.source, }; const tokenExpireAt = addShortTimeSpan(TOKEN_EXPIRES_IN); @@ -431,7 +433,7 @@ export class AuthController { const token = this.jwtService.sign(jwtpayload, { expiresIn: dto.expiresIn, - subject: `user|${user.id}`, + subject: user.id, }); const tokenExpireAt = addShortTimeSpan(dto.expiresIn); @@ -451,7 +453,7 @@ export class AuthController { type: SessionWithToken, }) @Post('@refresh') - async refreshToken(@Body() dto: RefreshTokenDto): Promise { + async refresh(@Body() dto: RefreshTokenDto): Promise { let session = await this.sessionService.findByRefreshToken(dto.refreshToken); if (!session) { throw new UnauthorizedException({ @@ -468,6 +470,7 @@ export class AuthController { } const payload = { + source: session.source, ns: session.ns, groups: session.groups, type: session.type, diff --git a/src/auth/entities/jwt.entity.ts b/src/auth/entities/jwt.entity.ts index b044c86..b28dd97 100644 --- a/src/auth/entities/jwt.entity.ts +++ b/src/auth/entities/jwt.entity.ts @@ -8,6 +8,7 @@ export interface Acl { */ export class JwtPayload { sid?: string; // 会话 ID + source?: string; // 来源 permissions?: string[]; // 受限的权限 ns?: string; // 该用户或资源所属的 namespace type?: string; // 登录端类型 diff --git a/src/session/entities/session.entity.ts b/src/session/entities/session.entity.ts index 437c64f..605ed2f 100644 --- a/src/session/entities/session.entity.ts +++ b/src/session/entities/session.entity.ts @@ -29,16 +29,21 @@ export class SessionDoc { refreshToken: string; /** - * 用户或第三方用户 - * "user|123456789" - * "github|123456789" - * "client|abcddfe" + * 用户或第三方用户 id */ @IsNotEmpty() @IsString() @Prop() subject: string; + /** + * 如果来自第三方,则会加上 source + */ + @IsOptional() + @IsString() + @Prop() + source?: string; + /** * 受限权限,如果提供这个字段,会覆盖用户的权限 */ diff --git a/test/auth-login-logout.e2e-spec.ts b/test/auth-login-logout.e2e-spec.ts index 81df3e6..181f9e2 100644 --- a/test/auth-login-logout.e2e-spec.ts +++ b/test/auth-login-logout.e2e-spec.ts @@ -104,7 +104,7 @@ describe('Web auth (e2e)', () => { const session: SessionWithToken = sessionResp.body; expect(sessionResp.statusCode).toBe(200); expect(session).toBeDefined(); - expect(session.subject).toBe(`user|${user.id}`); + expect(session.subject).toBe(user.id); // 刷新token const refreshTokenResp = await request(app.getHttpServer()) From c9ea977755094322510b34bf876263c164a7cacb Mon Sep 17 00:00:00 2001 From: zzswang Date: Thu, 27 Feb 2025 22:50:02 +0800 Subject: [PATCH 09/12] refactor: camelcase --- src/auth/auth.controller.ts | 14 +++++++++----- src/auth/dto/authorize-query.dto.ts | 6 +++++- src/auth/dto/github.dto.ts | 8 ++------ src/auth/dto/oauth.dto.ts | 4 ++-- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index bf3c128..476fcfb 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -156,13 +156,18 @@ export class AuthController { @ApiOperation({ operationId: 'getAuthorizer' }) @Get('authorizer') getAuthorizer(@Query() query: GetAuthorizerQuery): Authorizer { - const { provider, redirect_uri, state } = query; + const { + provider, + redirectUri: redirect_uri, + responseType: response_type = 'code', + state, + } = query; const clientId = config.oauthProvider.clientId(provider); const authorizeUrl = config.oauthProvider.authorizeUrl(provider); const params = new URLSearchParams({ client_id: clientId, - response_type: 'code', + response_type, ...(redirect_uri && { redirect_uri }), ...(state && { state }), }); @@ -186,8 +191,7 @@ export class AuthController { return this.loginByOAuth({ provider: 'github', code: githubDto.code, - grant_type: githubDto.grant_type, - redirect_uri: githubDto.redirect_uri, + redirectUri: githubDto.redirectUri, }); } @@ -202,7 +206,7 @@ export class AuthController { }) @Post('@loginByOAuth') async loginByOAuth(@Body() dto: OAuthDto): Promise { - const { provider, code, grant_type, redirect_uri } = dto; + const { provider, code, grantType: grant_type, redirectUri: redirect_uri } = dto; const clientId = config.oauthProvider.clientId(provider); const clientSecret = config.oauthProvider.clientSecret(provider); const accessTokenUrl = config.oauthProvider.accessTokenUrl(provider); diff --git a/src/auth/dto/authorize-query.dto.ts b/src/auth/dto/authorize-query.dto.ts index 5c9b875..4ecc9b5 100644 --- a/src/auth/dto/authorize-query.dto.ts +++ b/src/auth/dto/authorize-query.dto.ts @@ -7,7 +7,11 @@ export class GetAuthorizerQuery { @IsOptional() @IsString() - redirect_uri?: string; + redirectUri?: string; + + @IsOptional() + @IsString() + responseType?: string; @IsOptional() @IsString() diff --git a/src/auth/dto/github.dto.ts b/src/auth/dto/github.dto.ts index 24cafd9..3645ea7 100644 --- a/src/auth/dto/github.dto.ts +++ b/src/auth/dto/github.dto.ts @@ -7,13 +7,9 @@ export class GithubDto { @IsOptional() @IsString() - grant_type?: string; + redirectUri?: string; @IsOptional() @IsString() - redirect_uri?: string; - - @IsOptional() - @IsString() - repository_id?: string; + repositoryId?: string; } diff --git a/src/auth/dto/oauth.dto.ts b/src/auth/dto/oauth.dto.ts index 0b2a6a9..de4d4a9 100644 --- a/src/auth/dto/oauth.dto.ts +++ b/src/auth/dto/oauth.dto.ts @@ -11,9 +11,9 @@ export class OAuthDto { @IsNotEmpty() @IsString() - grant_type?: string; + grantType?: string; @IsOptional() @IsString() - redirect_uri?: string; + redirectUri?: string; } From e880976f7cdec9746df6b1d71a4c21a4d91c3a75 Mon Sep 17 00:00:00 2001 From: zzswang Date: Fri, 28 Feb 2025 09:34:16 +0800 Subject: [PATCH 10/12] feat: bump version --- openapi.json | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/openapi.json b/openapi.json index dbf9260..c5d8dfe 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "cafc0fb323e45f7e54fec5b8b1f6c2f3c8f7bf57bc03c88ed40c3ae406e0dee9", + "hash": "35f2b5e52d20746158669c1d8b6091b3372481c2b5c381b131f0bb87b7a2a366", "openapi": "3.0.0", "paths": { "/hello": { @@ -95,7 +95,15 @@ } }, { - "name": "redirect_uri", + "name": "redirectUri", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "responseType", "required": false, "in": "query", "schema": { @@ -3521,13 +3529,10 @@ "code": { "type": "string" }, - "grant_type": { - "type": "string" - }, - "redirect_uri": { + "redirectUri": { "type": "string" }, - "repository_id": { + "repositoryId": { "type": "string" } }, @@ -3544,10 +3549,10 @@ "code": { "type": "string" }, - "grant_type": { + "grantType": { "type": "string" }, - "redirect_uri": { + "redirectUri": { "type": "string" } }, From d8848bb185cbd2339664584a80ad241db29ad1ff Mon Sep 17 00:00:00 2001 From: zzswang Date: Fri, 28 Feb 2025 15:17:36 +0800 Subject: [PATCH 11/12] fix: oauth dto --- src/auth/dto/oauth.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/dto/oauth.dto.ts b/src/auth/dto/oauth.dto.ts index de4d4a9..1f9f7c6 100644 --- a/src/auth/dto/oauth.dto.ts +++ b/src/auth/dto/oauth.dto.ts @@ -9,7 +9,7 @@ export class OAuthDto { @IsString() code: string; - @IsNotEmpty() + @IsOptional() @IsString() grantType?: string; From 3362da117557da7a9831a5532fcc52d3e197ee9d Mon Sep 17 00:00:00 2001 From: zzswang Date: Fri, 28 Feb 2025 16:06:41 +0800 Subject: [PATCH 12/12] fix: assert env --- .env | 7 ------- README.md | 12 ++++++++++++ src/auth/auth.controller.ts | 7 +++++++ src/lib/lang/assert.ts | 16 ++++++++++++++-- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/.env b/.env index a5391fa..da2df51 100644 --- a/.env +++ b/.env @@ -33,10 +33,3 @@ JWT_SECRET_KEY=cksfkewkfoptyhiop534o239dkja23as # API KEY API_KEY=YHImpSchS4iEwVD1IxXp4012 - -GITHUB_CLIENT_ID=Iv23lizBaVPIiABBCHaz -GITHUB_CLIENT_SECRET=041f46399c1396ec27d16851c1aa2aa479a3f5a5 -GITHUB_AUTHORIZE_URL=https://github.com/login/oauth/authorize -GITHUB_ACCESS_TOKEN_URL=https://github.com/login/oauth/access_token -GITHUB_USER_INFO_URL=https://api.github.com/user -GITHUB_TID_FIELD=login diff --git a/README.md b/README.md index f41a344..de323ae 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,18 @@ $ pnpm test:cov PORT=9528 ``` +配置 Github 的 oauth 登录 + +``` +# GITHUB +GITHUB_CLIENT_ID=Iv23lizBaVPIiABBCHaz +GITHUB_CLIENT_SECRET=041f46399c1396ec27d16851c1aa2aa479a3f5a5 +GITHUB_AUTHORIZE_URL=https://github.com/login/oauth/authorize +GITHUB_ACCESS_TOKEN_URL=https://github.com/login/oauth/access_token +GITHUB_USER_INFO_URL=https://api.github.com/user +GITHUB_TID_FIELD=login +``` + ## 如何发布一个临时的 sdk 包 生成 openapi.json diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 476fcfb..347c270 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -20,6 +20,7 @@ import { JwtPayload } from 'src/auth'; import { CaptchaService } from 'src/captcha'; import * as config from 'src/config'; import { ErrorCodes } from 'src/constants'; +import { assertHttp } from 'src/lib/lang/assert'; import { addShortTimeSpan } from 'src/lib/lang/time'; import { OAuthService } from 'src/oauth'; import { CreateSessionDto, SessionService } from 'src/session'; @@ -211,6 +212,10 @@ export class AuthController { const clientSecret = config.oauthProvider.clientSecret(provider); const accessTokenUrl = config.oauthProvider.accessTokenUrl(provider); + assertHttp(!!clientId, `clientId of ${provider} not found.`); + assertHttp(!!clientSecret, `clientSecret of ${provider} not found.`); + assertHttp(!!accessTokenUrl, `accessTokenUrl of ${provider} not found.`); + const result = await this.oauthService.getAccessToken(accessTokenUrl, { client_id: clientId, client_secret: clientSecret, @@ -225,10 +230,12 @@ export class AuthController { // 获取第三方的用户信息 const userInfoUrl = config.oauthProvider.userInfoUrl(provider); + assertHttp(!!userInfoUrl, `userInfoUrl of ${provider} not found.`); const userInfo = await this.oauthService.getUserInfo(userInfoUrl, result.access_token); // 创建或更新第三方数据 const tidField = config.oauthProvider.tidField(provider); + assertHttp(!!tidField, `tidField of ${provider} not found.`); const tid = get(userInfo, tidField); const thirdParty = await this.thirdPartyService.upsert(tid, provider, { tid, diff --git a/src/lib/lang/assert.ts b/src/lib/lang/assert.ts index e648dfd..960ec1c 100644 --- a/src/lib/lang/assert.ts +++ b/src/lib/lang/assert.ts @@ -1,5 +1,17 @@ -export function assert(condition: boolean, msg = 'error occured') { +import { BadRequestException, HttpException } from '@nestjs/common'; + +export function assert(condition: boolean, msg = 'error occured', ErrConstructor = Error) { if (!condition) { - throw new Error(msg); + throw new ErrConstructor(msg); + } +} + +export function assertHttp( + condition: any, + message: string, + Exception: new (...args: any[]) => HttpException = BadRequestException +): asserts condition { + if (!condition) { + throw new Exception(message); } }