mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
feat: add user info edit verify (#4117)
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
"@aws-sdk/client-s3": "^3.400.0",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.17.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
|
||||
"@keyv/redis": "^2.7.0",
|
||||
"@nestjs/apollo": "^12.0.7",
|
||||
"@nestjs/common": "^10.2.4",
|
||||
"@nestjs/core": "^10.2.4",
|
||||
@@ -57,6 +58,7 @@
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"graphql-upload": "^16.0.2",
|
||||
"ioredis": "^5.3.2",
|
||||
"keyv": "^4.5.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nestjs-throttler-storage-redis": "^0.3.3",
|
||||
"next-auth": "4.22.5",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ConfigModule } from './config';
|
||||
import { MetricsModule } from './metrics';
|
||||
import { BusinessModules, Providers } from './modules';
|
||||
import { PrismaModule } from './prisma';
|
||||
import { SessionModule } from './session';
|
||||
import { StorageModule } from './storage';
|
||||
import { RateLimiterModule } from './throttler';
|
||||
|
||||
@@ -14,6 +15,7 @@ import { RateLimiterModule } from './throttler';
|
||||
ConfigModule.forRoot(),
|
||||
StorageModule.forRoot(),
|
||||
MetricsModule,
|
||||
SessionModule,
|
||||
RateLimiterModule,
|
||||
...BusinessModules,
|
||||
],
|
||||
|
||||
@@ -231,6 +231,15 @@ export interface AFFiNEConfig {
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
/**
|
||||
* redis database index
|
||||
*
|
||||
* Rate Limiter scope: database + 1
|
||||
*
|
||||
* Session scope: database + 2
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
database: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { BadRequestException, FactoryProvider, Logger } from '@nestjs/common';
|
||||
import { verify } from '@node-rs/argon2';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import Email, {
|
||||
@@ -14,6 +15,7 @@ import Google from 'next-auth/providers/google';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { SessionService } from '../../session';
|
||||
import { NewFeaturesKind } from '../users/types';
|
||||
import { isStaff } from '../users/utils';
|
||||
import { MailService } from './mailer';
|
||||
@@ -23,7 +25,12 @@ export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
|
||||
|
||||
export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
provide: NextAuthOptionsProvide,
|
||||
useFactory(config: Config, prisma: PrismaService, mailer: MailService) {
|
||||
useFactory(
|
||||
config: Config,
|
||||
prisma: PrismaService,
|
||||
mailer: MailService,
|
||||
session: SessionService
|
||||
) {
|
||||
const logger = new Logger('NextAuth');
|
||||
const prismaAdapter = PrismaAdapter(prisma);
|
||||
// createUser exists in the adapter
|
||||
@@ -72,15 +79,31 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
from: config.auth.email.sender,
|
||||
async sendVerificationRequest(params: SendVerificationRequestParams) {
|
||||
const { identifier, url, provider } = params;
|
||||
const { searchParams } = new URL(url);
|
||||
const callbackUrl = searchParams.get('callbackUrl') || '';
|
||||
const urlWithToken = new URL(url);
|
||||
const callbackUrl =
|
||||
urlWithToken.searchParams.get('callbackUrl') || '';
|
||||
if (!callbackUrl) {
|
||||
throw new Error('callbackUrl is not set');
|
||||
} else {
|
||||
const newCallbackUrl = new URL(callbackUrl, config.origin);
|
||||
|
||||
const token = nanoid();
|
||||
await session.set(token, identifier);
|
||||
newCallbackUrl.searchParams.set('token', token);
|
||||
|
||||
urlWithToken.searchParams.set(
|
||||
'callbackUrl',
|
||||
newCallbackUrl.toString()
|
||||
);
|
||||
}
|
||||
const result = await mailer.sendSignInEmail(url, {
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
});
|
||||
|
||||
const result = await mailer.sendSignInEmail(
|
||||
urlWithToken.toString(),
|
||||
{
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
}
|
||||
);
|
||||
logger.log(
|
||||
`send verification email success: ${result.accepted.join(', ')}`
|
||||
);
|
||||
@@ -277,5 +300,5 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
};
|
||||
return nextAuthOptions;
|
||||
},
|
||||
inject: [Config, PrismaService, MailService],
|
||||
inject: [Config, PrismaService, MailService, SessionService],
|
||||
};
|
||||
|
||||
@@ -127,7 +127,6 @@ export class NextAuthController {
|
||||
}
|
||||
|
||||
if (redirect?.endsWith('api/auth/error?error=AccessDenied')) {
|
||||
this.logger.debug(req.headers);
|
||||
if (!req.headers?.referer) {
|
||||
res.redirect('https://community.affine.pro/c/insider-general/');
|
||||
} else {
|
||||
@@ -145,7 +144,6 @@ export class NextAuthController {
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
this.logger.debug(providerId, action, req.headers);
|
||||
if (providerId === 'credentials') {
|
||||
res.send(JSON.stringify({ ok: true, url: redirect }));
|
||||
} else if (
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ForbiddenException, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
@@ -10,11 +14,13 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { Request } from 'express';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { SessionService } from '../../session';
|
||||
import { CloudThrottlerGuard, Throttle } from '../../throttler';
|
||||
import { UserType } from '../users/resolver';
|
||||
import { CurrentUser } from './guard';
|
||||
import { Auth, CurrentUser } from './guard';
|
||||
import { AuthService } from './service';
|
||||
|
||||
@ObjectType()
|
||||
@@ -37,14 +43,15 @@ export class TokenType {
|
||||
export class AuthResolver {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private auth: AuthService
|
||||
private auth: AuthService,
|
||||
private readonly session: SessionService
|
||||
) {}
|
||||
|
||||
@Throttle(20, 60)
|
||||
@ResolveField(() => TokenType)
|
||||
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
|
||||
if (user.id !== currentUser.id) {
|
||||
throw new ForbiddenException();
|
||||
throw new BadRequestException('Invalid user');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -80,58 +87,93 @@ export class AuthResolver {
|
||||
|
||||
@Throttle(5, 60)
|
||||
@Mutation(() => UserType)
|
||||
@Auth()
|
||||
async changePassword(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('id') id: string,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('token') token: string,
|
||||
@Args('newPassword') newPassword: string
|
||||
) {
|
||||
const user = await this.auth.changePassword(id, newPassword);
|
||||
ctx.req.user = user;
|
||||
const id = await this.session.get(token);
|
||||
if (!id || id !== user.id) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
await this.auth.changePassword(id, newPassword);
|
||||
await this.session.delete(token);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@Throttle(5, 60)
|
||||
@Mutation(() => UserType)
|
||||
@Auth()
|
||||
async changeEmail(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('id') id: string,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('token') token: string,
|
||||
@Args('email') email: string
|
||||
) {
|
||||
const user = await this.auth.changeEmail(id, email);
|
||||
ctx.req.user = user;
|
||||
const id = await this.session.get(token);
|
||||
if (!id || id !== user.id) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
await this.auth.changeEmail(id, email);
|
||||
await this.session.delete(token);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@Throttle(5, 60)
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendChangePasswordEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const url = `${this.config.baseUrl}${callbackUrl}`;
|
||||
const res = await this.auth.sendChangePasswordEmail(email, url);
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendChangePasswordEmail(email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Throttle(5, 60)
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendSetPasswordEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const url = `${this.config.baseUrl}${callbackUrl}`;
|
||||
const res = await this.auth.sendSetPasswordEmail(email, url);
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendSetPasswordEmail(email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Throttle(5, 60)
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendChangeEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const url = `${this.config.baseUrl}${callbackUrl}`;
|
||||
const res = await this.auth.sendChangeEmail(email, url);
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendChangeEmail(email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export class UserResolver {
|
||||
name: 'currentUser',
|
||||
description: 'Get current user',
|
||||
})
|
||||
async currentUser(@CurrentUser() user: User) {
|
||||
async currentUser(@CurrentUser() user: UserType) {
|
||||
const storedUser = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
});
|
||||
|
||||
@@ -186,8 +186,8 @@ type Mutation {
|
||||
addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList!
|
||||
signUp(name: String!, email: String!, password: String!): UserType!
|
||||
signIn(email: String!, password: String!): UserType!
|
||||
changePassword(id: String!, newPassword: String!): UserType!
|
||||
changeEmail(id: String!, email: String!): UserType!
|
||||
changePassword(token: String!, newPassword: String!): UserType!
|
||||
changeEmail(token: String!, email: String!): UserType!
|
||||
sendChangePasswordEmail(email: String!, callbackUrl: String!): Boolean!
|
||||
sendSetPasswordEmail(email: String!, callbackUrl: String!): Boolean!
|
||||
sendChangeEmail(email: String!, callbackUrl: String!): Boolean!
|
||||
|
||||
60
apps/server/src/session.ts
Normal file
60
apps/server/src/session.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { Global, Injectable, Module } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import Keyv from 'keyv';
|
||||
|
||||
import { Config } from './config';
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
private readonly cache: Keyv;
|
||||
private readonly prefix = 'session:';
|
||||
private readonly sessionTtl = 30 * 60 * 1000; // 30 min
|
||||
|
||||
constructor(protected readonly config: Config) {
|
||||
if (config.redis.enabled) {
|
||||
this.cache = new Keyv({
|
||||
store: new KeyvRedis(
|
||||
new Redis(config.redis.port, config.redis.host, {
|
||||
username: config.redis.username,
|
||||
password: config.redis.password,
|
||||
db: config.redis.database + 2,
|
||||
})
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.cache = new Keyv();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get session
|
||||
* @param key session key
|
||||
* @returns
|
||||
*/
|
||||
async get(key: string) {
|
||||
return this.cache.get(this.prefix + key);
|
||||
}
|
||||
|
||||
/**
|
||||
* set session
|
||||
* @param key session key
|
||||
* @param value session value
|
||||
* @param sessionTtl session ttl (ms), default 30 min
|
||||
* @returns return true if success
|
||||
*/
|
||||
async set(key: string, value?: any, sessionTtl = this.sessionTtl) {
|
||||
return this.cache.set(this.prefix + key, value, sessionTtl);
|
||||
}
|
||||
|
||||
async delete(key: string) {
|
||||
return this.cache.delete(this.prefix + key);
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SessionService],
|
||||
exports: [SessionService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
42
apps/server/src/tests/session.spec.ts
Normal file
42
apps/server/src/tests/session.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/// <reference types="../global.d.ts" />
|
||||
import { equal } from 'node:assert';
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { ConfigModule } from '../config';
|
||||
import { SessionModule, SessionService } from '../session';
|
||||
|
||||
let session: SessionService;
|
||||
let module: TestingModule;
|
||||
|
||||
// cleanup database before each test
|
||||
beforeEach(async () => {
|
||||
const client = new PrismaClient();
|
||||
await client.$connect();
|
||||
await client.user.deleteMany({});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot(), SessionModule],
|
||||
}).compile();
|
||||
session = module.get(SessionService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should be able to set session', async () => {
|
||||
await session.set('test', 'value');
|
||||
equal(await session.get('test'), 'value');
|
||||
});
|
||||
|
||||
test('should be expired by ttl', async () => {
|
||||
await session.set('test', 'value', 100);
|
||||
equal(await session.get('test'), 'value');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
equal(await session.get('test'), undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user