mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
feat(server): auth server (#2773)
This commit is contained in:
@@ -49,10 +49,17 @@ class AuthGuard implements CanActivate {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
const [type, jwt] = token.split(' ') ?? [];
|
||||
|
||||
const claims = this.auth.verify(token);
|
||||
req.user = await this.prisma.user.findUnique({ where: { id: claims.id } });
|
||||
return !!req.user;
|
||||
if (type === 'Bearer') {
|
||||
const claims = await this.auth.verify(jwt);
|
||||
req.user = await this.prisma.user.findUnique({
|
||||
where: { id: claims.id },
|
||||
});
|
||||
return !!req.user;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { NextAuthController } from './next-auth.controller';
|
||||
import { AuthResolver } from './resolver';
|
||||
import { AuthService } from './service';
|
||||
|
||||
@@ -7,6 +8,7 @@ import { AuthService } from './service';
|
||||
@Module({
|
||||
providers: [AuthService, AuthResolver],
|
||||
exports: [AuthService],
|
||||
controllers: [NextAuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
export * from './guard';
|
||||
|
||||
158
apps/server/src/modules/auth/next-auth.controller.ts
Normal file
158
apps/server/src/modules/auth/next-auth.controller.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Next,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { AuthAction, AuthOptions } from 'next-auth';
|
||||
import { AuthHandler } from 'next-auth/core';
|
||||
import Github from 'next-auth/providers/github';
|
||||
import Google from 'next-auth/providers/google';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
import { getUtcTimestamp, type UserClaim } from './service';
|
||||
|
||||
const BASE_URL = '/api/auth/';
|
||||
|
||||
@Controller(BASE_URL)
|
||||
export class NextAuthController {
|
||||
private readonly nextAuthOptions: AuthOptions;
|
||||
|
||||
constructor(readonly config: Config, readonly prisma: PrismaService) {
|
||||
this.nextAuthOptions = {
|
||||
providers: [],
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
adapter: PrismaAdapter(prisma),
|
||||
};
|
||||
|
||||
if (config.auth.oauthProviders.github) {
|
||||
this.nextAuthOptions.providers.push(
|
||||
Github({
|
||||
clientId: config.auth.oauthProviders.github.clientId,
|
||||
clientSecret: config.auth.oauthProviders.github.clientSecret,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (config.auth.oauthProviders.google) {
|
||||
this.nextAuthOptions.providers.push(
|
||||
Google({
|
||||
clientId: config.auth.oauthProviders.google.clientId,
|
||||
clientSecret: config.auth.oauthProviders.google.clientSecret,
|
||||
})
|
||||
);
|
||||
}
|
||||
this.nextAuthOptions.jwt = {
|
||||
encode: async ({ token, maxAge }) => {
|
||||
if (!token?.email) {
|
||||
throw new BadRequestException('Missing email in jwt token');
|
||||
}
|
||||
const user = await this.prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: token.email,
|
||||
},
|
||||
});
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
iat: now,
|
||||
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
|
||||
iss: this.config.serverId,
|
||||
sub: user.id,
|
||||
aud: user.name,
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
this.config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
},
|
||||
decode: async ({ token }) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const { name, email, id } = (
|
||||
await jwtVerify(token, this.config.auth.publicKey, {
|
||||
algorithms: [Algorithm.ES256],
|
||||
iss: [this.config.serverId],
|
||||
leeway: this.config.auth.leeway,
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
})
|
||||
).data as UserClaim;
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
sub: id,
|
||||
};
|
||||
},
|
||||
};
|
||||
this.nextAuthOptions.secret ??= config.auth.nextAuthSecret;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Post()
|
||||
async auth(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Query() query: Record<string, any>,
|
||||
@Next() next: NextFunction
|
||||
) {
|
||||
const nextauth = req.url // start with request url
|
||||
.slice(BASE_URL.length) // make relative to baseUrl
|
||||
.replace(/\?.*/, '') // remove query part, use only path part
|
||||
.split('/') as AuthAction[]; // as array of strings;
|
||||
const { status, headers, body, redirect, cookies } = await AuthHandler({
|
||||
req: {
|
||||
body: req.body,
|
||||
query: query,
|
||||
method: req.method,
|
||||
action: nextauth[0],
|
||||
providerId: nextauth[1],
|
||||
error: query.error ?? nextauth[1],
|
||||
cookies: req.cookies,
|
||||
},
|
||||
options: this.nextAuthOptions,
|
||||
});
|
||||
if (status) {
|
||||
res.status(status);
|
||||
}
|
||||
if (headers) {
|
||||
for (const { key, value } of headers) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
if (cookies) {
|
||||
for (const cookie of cookies) {
|
||||
res.cookie(cookie.name, cookie.value, cookie.options);
|
||||
}
|
||||
}
|
||||
if (redirect) {
|
||||
res.redirect(redirect);
|
||||
} else if (typeof body === 'string') {
|
||||
res.send(body);
|
||||
} else if (body && typeof body === 'object') {
|
||||
res.json(body);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,4 +50,16 @@ export class AuthResolver {
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Mutation(() => UserType)
|
||||
async signUp(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string,
|
||||
@Args('name') name: string
|
||||
) {
|
||||
const user = await this.auth.register(name, email, password);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,88 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { compare, hash } from '@node-rs/bcrypt';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import type { User } from '@prisma/client';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
|
||||
type UserClaim = Pick<User, 'id' | 'name' | 'email'>;
|
||||
export type UserClaim = Pick<User, 'id' | 'name' | 'email' | 'createdAt'>;
|
||||
|
||||
export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private config: Config, private prisma: PrismaService) {}
|
||||
|
||||
sign(user: UserClaim) {
|
||||
return jwt.sign(user, this.config.auth.privateKey, {
|
||||
algorithm: 'ES256',
|
||||
subject: user.id,
|
||||
issuer: this.config.serverId,
|
||||
expiresIn: this.config.auth.accessTokenExpiresIn,
|
||||
});
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
iat: now,
|
||||
exp: now + this.config.auth.accessTokenExpiresIn,
|
||||
iss: this.config.serverId,
|
||||
sub: user.id,
|
||||
aud: user.name,
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
this.config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
refresh(user: UserClaim) {
|
||||
return jwt.sign(user, this.config.auth.privateKey, {
|
||||
algorithm: 'ES256',
|
||||
subject: user.id,
|
||||
issuer: this.config.serverId,
|
||||
expiresIn: this.config.auth.refreshTokenExpiresIn,
|
||||
});
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
exp: now + this.config.auth.refreshTokenExpiresIn,
|
||||
iat: now,
|
||||
iss: this.config.serverId,
|
||||
sub: user.id,
|
||||
aud: user.name,
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
this.config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
verify(token: string) {
|
||||
async verify(token: string) {
|
||||
try {
|
||||
return jwt.verify(token, this.config.auth.publicKey, {
|
||||
algorithms: ['ES256'],
|
||||
}) as UserClaim;
|
||||
return (
|
||||
await jwtVerify(token, this.config.auth.publicKey, {
|
||||
algorithms: [Algorithm.ES256],
|
||||
iss: [this.config.serverId],
|
||||
leeway: this.config.auth.leeway,
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
})
|
||||
).data as UserClaim;
|
||||
} catch (e) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
@@ -58,9 +102,13 @@ export class AuthService {
|
||||
if (!user.password) {
|
||||
throw new BadRequestException('User has no password');
|
||||
}
|
||||
|
||||
const equal = await compare(password, user.password);
|
||||
|
||||
let equal = false;
|
||||
try {
|
||||
equal = await verify(user.password, password);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new InternalServerErrorException(e, 'Verify password failed');
|
||||
}
|
||||
if (!equal) {
|
||||
throw new UnauthorizedException('Invalid password');
|
||||
}
|
||||
@@ -69,8 +117,6 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async register(name: string, email: string, password: string): Promise<User> {
|
||||
const hashedPassword = await hash(password);
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
@@ -81,6 +127,8 @@ export class AuthService {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(password);
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
|
||||
23
apps/server/src/modules/storage/fs.ts
Normal file
23
apps/server/src/modules/storage/fs.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { FileUpload } from '../../types';
|
||||
|
||||
@Injectable()
|
||||
export class FSService {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
async writeFile(key: string, file: FileUpload) {
|
||||
const dest = this.config.objectStorage.fs.path;
|
||||
await mkdir(dest, { recursive: true });
|
||||
const destFile = join(dest, key);
|
||||
await pipeline(file.createReadStream(), createWriteStream(destFile));
|
||||
|
||||
return `/assets/${destFile}`;
|
||||
}
|
||||
}
|
||||
11
apps/server/src/modules/storage/index.ts
Normal file
11
apps/server/src/modules/storage/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FSService } from './fs';
|
||||
import { S3 } from './s3';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Module({
|
||||
providers: [S3, StorageService, FSService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
15
apps/server/src/modules/storage/s3.ts
Normal file
15
apps/server/src/modules/storage/s3.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
import { FactoryProvider } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../config';
|
||||
|
||||
export const S3_SERVICE = Symbol('S3_SERVICE');
|
||||
|
||||
export const S3: FactoryProvider<S3Client> = {
|
||||
provide: S3_SERVICE,
|
||||
useFactory: (config: Config) => {
|
||||
const s3 = new S3Client(config.objectStorage.config);
|
||||
return s3;
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
31
apps/server/src/modules/storage/storage.service.ts
Normal file
31
apps/server/src/modules/storage/storage.service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { FileUpload } from '../../types';
|
||||
import { FSService } from './fs';
|
||||
import { S3_SERVICE } from './s3';
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
constructor(
|
||||
@Inject(S3_SERVICE) private readonly s3: S3Client,
|
||||
private readonly fs: FSService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
async uploadFile(key: string, file: FileUpload) {
|
||||
if (this.config.objectStorage.enable) {
|
||||
await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Body: file.createReadStream(),
|
||||
Bucket: this.config.objectStorage.config.bucket,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
return `https://avatar.affineassets.com/${key}`;
|
||||
} else {
|
||||
return this.fs.writeFile(key, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { StorageModule } from '../storage';
|
||||
import { UserResolver } from './resolver';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule],
|
||||
providers: [UserResolver],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
ID,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Query,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User } from '@prisma/client';
|
||||
// @ts-expect-error graphql-upload is not typed
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
import type { FileUpload } from '../../types';
|
||||
import { Auth } from '../auth/guard';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
|
||||
@ObjectType()
|
||||
export class UserType implements Partial<User> {
|
||||
@@ -21,9 +35,13 @@ export class UserType implements Partial<User> {
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@Resolver(() => UserType)
|
||||
export class UserResolver {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storage: StorageService
|
||||
) {}
|
||||
|
||||
@Query(() => UserType, {
|
||||
name: 'user',
|
||||
@@ -34,4 +52,24 @@ export class UserResolver {
|
||||
where: { email },
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => UserType, {
|
||||
name: 'uploadAvatar',
|
||||
description: 'Upload user avatar',
|
||||
})
|
||||
async uploadAvatar(
|
||||
@Args('id') id: string,
|
||||
@Args({ name: 'avatar', type: () => GraphQLUpload })
|
||||
avatar: FileUpload
|
||||
) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) {
|
||||
throw new BadRequestException(`User ${id} not found`);
|
||||
}
|
||||
const url = await this.storage.uploadFile(`${id}-avatar`, avatar);
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { avatarUrl: url },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user