feat: add user info edit verify (#4117)

This commit is contained in:
DarkSky
2023-09-02 00:59:33 +08:00
committed by GitHub
parent db3a6efaf3
commit 3c4f45bcb6
16 changed files with 241 additions and 46 deletions

View File

@@ -9,7 +9,12 @@ import { changeEmailMutation, changePasswordMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import type { ReactElement } from 'react';
import { useCallback } from 'react';
import { type LoaderFunction, redirect, useParams } from 'react-router-dom';
import {
type LoaderFunction,
redirect,
useParams,
useSearchParams,
} from 'react-router-dom';
import { z } from 'zod';
import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status';
@@ -27,6 +32,7 @@ const authTypeSchema = z.enum([
export const AuthPage = (): ReactElement | null => {
const user = useCurrentUser();
const { authType } = useParams();
const [searchParams] = useSearchParams();
const { trigger: changePassword } = useMutation({
mutation: changePasswordMutation,
});
@@ -39,22 +45,22 @@ export const AuthPage = (): ReactElement | null => {
const onChangeEmail = useCallback(
async (email: string) => {
const res = await changeEmail({
id: user.id,
token: searchParams.get('token') || '',
newEmail: email,
});
return !!res?.changeEmail;
},
[changeEmail, user.id]
[changeEmail, searchParams]
);
const onSetPassword = useCallback(
(password: string) => {
changePassword({
id: user.id,
token: searchParams.get('token') || '',
newPassword: password,
}).catch(console.error);
},
[changePassword, user.id]
[changePassword, searchParams]
);
const onOpenAffine = useCallback(() => {
jumpToIndex(RouteLogic.REPLACE);

View File

@@ -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",

View File

@@ -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,
],

View File

@@ -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;
};

View File

@@ -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],
};

View File

@@ -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 (

View File

@@ -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;
}
}

View File

@@ -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 },
});

View File

@@ -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!

View 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 {}

View 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);
});